Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bbc77ffc8 | |||
| 19d3dcef69 | |||
| 32a279e0ae | |||
| 7258509528 | |||
| 7fc28f4374 | |||
| 89ab3673c4 | |||
| 84c882ac0b |
@@ -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,1777974738862,518123ebb7461c8343d5ad7d08a9bc31ca5555df3d9e09d36442cad4e5a4dcaa
|
version.json,1779745850580,c83e8cef9f09921b50bea3e26017c353fb516d339f57fbd0a8d3696f1ffc0e42
|
||||||
index.html,1777974744949,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
index.html,1779745856220,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
||||||
flutter_service_worker.js,1777974838520,527efc67156a0e2688a3aca09ef3f967cbb514258c91dc1d8ad1d6a4935e2c65
|
flutter_bootstrap.js,1779745856203,79bfcfd09b63ba083702fd55c660d283686d9571b49febd8dcab49abbdf6f683
|
||||||
assets/FontManifest.json,1777974834864,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
flutter_service_worker.js,1779745934512,3d18931ea97b2eeeba61c4fe7c0c8d736cc42ef9b8c2a6e4ec21e83e14e351ae
|
||||||
flutter_bootstrap.js,1777974744934,09a1770005261de742912a7cf492d739d3e263d2383f53cda5ba5bac6896c39c
|
assets/FontManifest.json,1779745931038,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
||||||
assets/AssetManifest.json,1777974834864,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
|
assets/AssetManifest.bin.json,1779745931038,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
|
||||||
assets/AssetManifest.bin,1777974834864,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
|
assets/AssetManifest.bin,1779745931038,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
|
||||||
assets/AssetManifest.bin.json,1777974834864,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
|
assets/AssetManifest.json,1779745931038,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
|
||||||
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1777974837564,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
assets/shaders/ink_sparkle.frag,1779745931235,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
||||||
assets/shaders/ink_sparkle.frag,1777974835049,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1779745933681,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
||||||
assets/fonts/MaterialIcons-Regular.otf,1777974837570,213705f583b626b88f6f0c7a122a13567b6492f560ad5284176ef149f0b51fef
|
assets/fonts/MaterialIcons-Regular.otf,1779745933686,710dc8fc35289048b52970355f64206fb1b2c5e67c71ae77a46b53f0e2daecd6
|
||||||
assets/NOTICES,1777974834870,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e
|
assets/NOTICES,1779745931041,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e
|
||||||
main.dart.js,1777974833676,fbb6da7a84cb69d9dfb2a92eac87571303dadec0af700067d2d66ed69db416e8
|
main.dart.js,1779745928953,60d92269024a5be234c7da2ebb889584e20c66a262b28f6d531a3f90c83767b3
|
||||||
|
|||||||
@@ -47,3 +47,4 @@ lib/config/env.dev.dart
|
|||||||
functions/.env
|
functions/.env
|
||||||
.env
|
.env
|
||||||
env.dart
|
env.dart
|
||||||
|
functions/.env.local
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
## 25/05/2026
|
||||||
|
Ajout d'un assistant IA pour la gestion des équipments dans un événement. Il permet de suggérer des equipements selon les informations qui lui sont données (copier un événement similaire, lire un devis, etc.) et de faire des recommandations pour optimiser la préparation d'un événement.
|
||||||
|
|
||||||
## 04/05/2026
|
## 04/05/2026
|
||||||
Optimisation du lancement de l'application et amélioration de la gestion du cache.
|
Optimisation du lancement de l'application et amélioration de la gestion du cache.
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ SMTP_PASS="aL8@Rx8xqFrNij$a"
|
|||||||
# URL de l'application
|
# URL de l'application
|
||||||
APP_URL="https://app.em2events.fr"
|
APP_URL="https://app.em2events.fr"
|
||||||
|
|
||||||
|
GEMINI_API_KEY="AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,11 @@
|
|||||||
* Architecture backend sécurisée avec authentification et permissions
|
* Architecture backend sécurisée avec authentification et permissions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Charger les variables d'environnement depuis .env
|
// Charger les variables d'environnement depuis .env.local (développement)
|
||||||
require('dotenv').config();
|
// ou .env (production Firebase)
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '.env.local') });
|
||||||
|
require('dotenv').config({ path: path.join(__dirname, '.env') });
|
||||||
|
|
||||||
const { onRequest, onCall } = require("firebase-functions/v2/https");
|
const { onRequest, onCall } = require("firebase-functions/v2/https");
|
||||||
const { onSchedule } = require("firebase-functions/v2/scheduler");
|
const { onSchedule } = require("firebase-functions/v2/scheduler");
|
||||||
@@ -17,6 +20,7 @@ const { Storage } = require('@google-cloud/storage');
|
|||||||
const auth = require('./utils/auth');
|
const auth = require('./utils/auth');
|
||||||
const helpers = require('./utils/helpers');
|
const helpers = require('./utils/helpers');
|
||||||
const { generateTTS } = require('./generateTTS');
|
const { generateTTS } = require('./generateTTS');
|
||||||
|
const { handleAiEquipmentProposal } = require('./aiEquipmentProposal');
|
||||||
|
|
||||||
// Initialisation sécurisée
|
// Initialisation sécurisée
|
||||||
if (!admin.apps.length) {
|
if (!admin.apps.length) {
|
||||||
@@ -33,6 +37,13 @@ const httpOptions = {
|
|||||||
// Version: 2.0 - Ajout de l'invoker public pour résoudre les problèmes CORS
|
// Version: 2.0 - Ajout de l'invoker public pour résoudre les problèmes CORS
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Options dédiées pour les traitements IA potentiellement longs.
|
||||||
|
const aiHttpOptions = {
|
||||||
|
...httpOptions,
|
||||||
|
timeoutSeconds: 300,
|
||||||
|
memory: '1GiB',
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CORS Middleware
|
// CORS Middleware
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -4544,3 +4555,20 @@ exports.generateTTSV2 = onRequest(ttsHttpOptions, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AI - Assistant Logisticien (Gemini avec function calling côté serveur)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
exports.aiEquipmentProposal = onRequest(aiHttpOptions, withCors(async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Authentification Firebase obligatoire (pas de clé API côté client)
|
||||||
|
await auth.authenticateUser(req);
|
||||||
|
await handleAiEquipmentProposal(req, res);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[aiEquipmentProposal] Error:', error);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
|||||||
Generated
+19
-5
@@ -8,11 +8,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@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",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"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",
|
||||||
"firebase-functions": "^7.0.3",
|
"firebase-functions": "^7.2.5",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"nodemailer": "^6.10.1"
|
"nodemailer": "^6.10.1"
|
||||||
},
|
},
|
||||||
@@ -785,6 +786,15 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@google/generative-ai": {
|
||||||
|
"version": "0.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.21.0.tgz",
|
||||||
|
"integrity": "sha512-7XhUbtnlkSEZK15kN3t+tzIMxsbKm/dSkKBFalj+20NvPKe1kBY7mR2P7vuijEn+f06z5+A8bVGKO0v39cr6Wg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@grpc/grpc-js": {
|
"node_modules/@grpc/grpc-js": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
|
||||||
@@ -3354,9 +3364,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/firebase-functions": {
|
"node_modules/firebase-functions": {
|
||||||
"version": "7.0.3",
|
"version": "7.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-7.2.5.tgz",
|
||||||
"integrity": "sha512-DiIjIUv0OS4KEAA3jqyIc7ymZKdcmMcaXy7FCCkrDQo/1CVMbDDWMdZIslmsgSjldA2nhau1dTE/6JQI8Urjjw==",
|
"integrity": "sha512-K+pP0AknluAguLRbD96hibyXbnOgwnvd4hkExWdGrxnNCLoj8LBFj08uvJYxyvhsCgYzQumrUaHBW4lsXKSiRg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3375,7 +3385,8 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@apollo/server": "^5.2.0",
|
"@apollo/server": "^5.2.0",
|
||||||
"@as-integrations/express4": "^1.1.2",
|
"@as-integrations/express4": "^1.1.2",
|
||||||
"firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0"
|
"firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0",
|
||||||
|
"graphql": "^16.12.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@apollo/server": {
|
"@apollo/server": {
|
||||||
@@ -3383,6 +3394,9 @@
|
|||||||
},
|
},
|
||||||
"@as-integrations/express4": {
|
"@as-integrations/express4": {
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"graphql": {
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,11 +16,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@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",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"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",
|
||||||
"firebase-functions": "^7.0.3",
|
"firebase-functions": "^7.2.5",
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"nodemailer": "^6.10.1"
|
"nodemailer": "^6.10.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.1.23';
|
static const String version = '1.2.1';
|
||||||
|
|
||||||
/// 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';
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ ReturnStatus returnStatusFromString(String? status) {
|
|||||||
class EventEquipment {
|
class EventEquipment {
|
||||||
final String equipmentId; // ID de l'équipement
|
final String equipmentId; // ID de l'équipement
|
||||||
final int quantity; // Quantité initiale assignée
|
final int quantity; // Quantité initiale assignée
|
||||||
|
final String? rationale; // Explication/Justification (ex: IA alternative)
|
||||||
final bool isPrepared; // Validé en préparation
|
final bool isPrepared; // Validé en préparation
|
||||||
final bool isLoaded; // Validé au chargement
|
final bool isLoaded; // Validé au chargement
|
||||||
final bool isUnloaded; // Validé au déchargement
|
final bool isUnloaded; // Validé au déchargement
|
||||||
@@ -194,6 +195,7 @@ class EventEquipment {
|
|||||||
EventEquipment({
|
EventEquipment({
|
||||||
required this.equipmentId,
|
required this.equipmentId,
|
||||||
this.quantity = 1,
|
this.quantity = 1,
|
||||||
|
this.rationale,
|
||||||
this.isPrepared = false,
|
this.isPrepared = false,
|
||||||
this.isLoaded = false,
|
this.isLoaded = false,
|
||||||
this.isUnloaded = false,
|
this.isUnloaded = false,
|
||||||
@@ -212,6 +214,7 @@ class EventEquipment {
|
|||||||
return EventEquipment(
|
return EventEquipment(
|
||||||
equipmentId: map['equipmentId'] ?? '',
|
equipmentId: map['equipmentId'] ?? '',
|
||||||
quantity: map['quantity'] ?? 1,
|
quantity: map['quantity'] ?? 1,
|
||||||
|
rationale: map['rationale'],
|
||||||
isPrepared: map['isPrepared'] ?? false,
|
isPrepared: map['isPrepared'] ?? false,
|
||||||
isLoaded: map['isLoaded'] ?? false,
|
isLoaded: map['isLoaded'] ?? false,
|
||||||
isUnloaded: map['isUnloaded'] ?? false,
|
isUnloaded: map['isUnloaded'] ?? false,
|
||||||
@@ -231,6 +234,7 @@ class EventEquipment {
|
|||||||
return {
|
return {
|
||||||
'equipmentId': equipmentId,
|
'equipmentId': equipmentId,
|
||||||
'quantity': quantity,
|
'quantity': quantity,
|
||||||
|
'rationale': rationale,
|
||||||
'isPrepared': isPrepared,
|
'isPrepared': isPrepared,
|
||||||
'isLoaded': isLoaded,
|
'isLoaded': isLoaded,
|
||||||
'isUnloaded': isUnloaded,
|
'isUnloaded': isUnloaded,
|
||||||
@@ -249,6 +253,7 @@ class EventEquipment {
|
|||||||
EventEquipment copyWith({
|
EventEquipment copyWith({
|
||||||
String? equipmentId,
|
String? equipmentId,
|
||||||
int? quantity,
|
int? quantity,
|
||||||
|
String? rationale,
|
||||||
bool? isPrepared,
|
bool? isPrepared,
|
||||||
bool? isLoaded,
|
bool? isLoaded,
|
||||||
bool? isUnloaded,
|
bool? isUnloaded,
|
||||||
@@ -265,6 +270,7 @@ class EventEquipment {
|
|||||||
return EventEquipment(
|
return EventEquipment(
|
||||||
equipmentId: equipmentId ?? this.equipmentId,
|
equipmentId: equipmentId ?? this.equipmentId,
|
||||||
quantity: quantity ?? this.quantity,
|
quantity: quantity ?? this.quantity,
|
||||||
|
rationale: rationale ?? this.rationale,
|
||||||
isPrepared: isPrepared ?? this.isPrepared,
|
isPrepared: isPrepared ?? this.isPrepared,
|
||||||
isLoaded: isLoaded ?? this.isLoaded,
|
isLoaded: isLoaded ?? this.isLoaded,
|
||||||
isUnloaded: isUnloaded ?? this.isUnloaded,
|
isUnloaded: isUnloaded ?? this.isUnloaded,
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
/// Représente un tour de conversation dans le chat.
|
||||||
|
class AiAssistantChatTurn {
|
||||||
|
final bool isUser;
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
const AiAssistantChatTurn({required this.isUser, required this.text});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Document à attacher pour demander à l'IA d'analyser un devis, etc.
|
||||||
|
class AiEquipmentDocument {
|
||||||
|
final String base64Data;
|
||||||
|
final String mimeType;
|
||||||
|
final String? fileName;
|
||||||
|
|
||||||
|
const AiEquipmentDocument({
|
||||||
|
required this.base64Data,
|
||||||
|
required this.mimeType,
|
||||||
|
this.fileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Un item proposé par l'IA dans la liste de matériel.
|
||||||
|
class AiEquipmentProposalItem {
|
||||||
|
final String equipmentId;
|
||||||
|
final int quantity;
|
||||||
|
final String rationale;
|
||||||
|
|
||||||
|
const AiEquipmentProposalItem({
|
||||||
|
required this.equipmentId,
|
||||||
|
required this.quantity,
|
||||||
|
required this.rationale,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Métadonnées pour un container proposé par l'IA.
|
||||||
|
class AiEquipmentProposalContainer {
|
||||||
|
final String containerId;
|
||||||
|
final String rationale;
|
||||||
|
final List<String> equipmentIds;
|
||||||
|
final List<String> matchingEquipmentIds;
|
||||||
|
final List<String> missingEquipmentIds;
|
||||||
|
final bool partial;
|
||||||
|
final bool? available;
|
||||||
|
final dynamic availabilityDetail;
|
||||||
|
|
||||||
|
const AiEquipmentProposalContainer({
|
||||||
|
required this.containerId,
|
||||||
|
required this.rationale,
|
||||||
|
this.equipmentIds = const [],
|
||||||
|
this.matchingEquipmentIds = const [],
|
||||||
|
this.missingEquipmentIds = const [],
|
||||||
|
this.partial = false,
|
||||||
|
this.available,
|
||||||
|
this.availabilityDetail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Proposition complète retournée par l'IA.
|
||||||
|
class AiEquipmentProposal {
|
||||||
|
final String summary;
|
||||||
|
final List<AiEquipmentProposalItem> items;
|
||||||
|
|
||||||
|
/// Équipements individuels prêts à être injectés dans l'état local de l'événement.
|
||||||
|
final List<EventEquipment> asEventEquipment;
|
||||||
|
|
||||||
|
/// Containers (métadonnées) proposés par l'IA.
|
||||||
|
final List<AiEquipmentProposalContainer> containers;
|
||||||
|
|
||||||
|
List<String> get containerIds => containers.map((c) => c.containerId).toList();
|
||||||
|
|
||||||
|
const AiEquipmentProposal({
|
||||||
|
required this.summary,
|
||||||
|
required this.items,
|
||||||
|
required this.asEventEquipment,
|
||||||
|
required this.containers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Réponse complète de l'assistant IA (message + proposition optionnelle).
|
||||||
|
class AiEquipmentAssistantResponse {
|
||||||
|
final String assistantMessage;
|
||||||
|
final AiEquipmentProposal? proposal;
|
||||||
|
final List<String> debugLogs;
|
||||||
|
|
||||||
|
const AiEquipmentAssistantResponse({
|
||||||
|
required this.assistantMessage,
|
||||||
|
this.proposal,
|
||||||
|
this.debugLogs = const [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service assistant IA logisticien.
|
||||||
|
/// Délègue tous les appels Gemini à la Cloud Function [aiEquipmentProposal].
|
||||||
|
/// L'authentification Firebase (token Bearer) suffit — aucune clé API côté client.
|
||||||
|
class AiEquipmentAssistantService {
|
||||||
|
final ApiService _apiService;
|
||||||
|
|
||||||
|
AiEquipmentAssistantService({ApiService? apiService})
|
||||||
|
: _apiService = apiService ?? FirebaseFunctionsApiService();
|
||||||
|
|
||||||
|
/// Envoie un message et retourne la réponse de l'assistant IA.
|
||||||
|
Future<AiEquipmentAssistantResponse> generateProposal({
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
required List<AiAssistantChatTurn> history,
|
||||||
|
required String userMessage,
|
||||||
|
String? eventTypeId,
|
||||||
|
String? excludeEventId,
|
||||||
|
List<EventEquipment> currentAssignedEquipment = const [],
|
||||||
|
List<EventEquipment> workingProposalEquipment = const [],
|
||||||
|
AiEquipmentDocument? document,
|
||||||
|
}) async {
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
'startDate': startDate.toIso8601String(),
|
||||||
|
'endDate': endDate.toIso8601String(),
|
||||||
|
'userMessage': userMessage.trim(),
|
||||||
|
'history': history
|
||||||
|
.where((turn) => turn.text.trim().isNotEmpty)
|
||||||
|
.map((turn) => {'isUser': turn.isUser, 'text': turn.text.trim()})
|
||||||
|
.toList(),
|
||||||
|
'currentEquipment': currentAssignedEquipment
|
||||||
|
.map((eq) => {'equipmentId': eq.equipmentId, 'quantity': eq.quantity})
|
||||||
|
.toList(),
|
||||||
|
'workingProposal': workingProposalEquipment
|
||||||
|
.map((eq) => {'equipmentId': eq.equipmentId, 'quantity': eq.quantity})
|
||||||
|
.toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (eventTypeId != null) payload['eventTypeId'] = eventTypeId;
|
||||||
|
if (excludeEventId != null) payload['excludeEventId'] = excludeEventId;
|
||||||
|
|
||||||
|
if (document != null) {
|
||||||
|
payload['document'] = {
|
||||||
|
'mimeType': document.mimeType,
|
||||||
|
'data': document.base64Data,
|
||||||
|
if (document.fileName != null) 'fileName': document.fileName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DebugLog.info('[AiEquipmentAssistantService] Calling aiEquipmentProposal Cloud Function');
|
||||||
|
|
||||||
|
final result = await _apiService.call('aiEquipmentProposal', payload);
|
||||||
|
final assistantMessage = result['assistantMessage']?.toString().trim() ?? '';
|
||||||
|
final proposal = _parseProposal(result['proposal']);
|
||||||
|
|
||||||
|
final rawLogs = result['debugLogs'];
|
||||||
|
final debugLogs = (rawLogs is List) ? rawLogs.map((e) => e.toString()).toList() : <String>[];
|
||||||
|
|
||||||
|
DebugLog.info(
|
||||||
|
'[AiEquipmentAssistantService] Response received, items: ${proposal?.items.length ?? 0}',
|
||||||
|
);
|
||||||
|
|
||||||
|
return AiEquipmentAssistantResponse(
|
||||||
|
assistantMessage: assistantMessage.isNotEmpty
|
||||||
|
? assistantMessage
|
||||||
|
: 'Je n\'ai pas pu générer de réponse.',
|
||||||
|
proposal: proposal,
|
||||||
|
debugLogs: debugLogs,
|
||||||
|
);
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
DebugLog.error('[AiEquipmentAssistantService] API error', e);
|
||||||
|
if (e.isUnauthorized) {
|
||||||
|
throw Exception('Vous n\'êtes pas authentifié. Reconnectez-vous et réessayez.');
|
||||||
|
}
|
||||||
|
throw Exception('Erreur du service IA (${e.statusCode}): ${e.message}');
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[AiEquipmentAssistantService] Error', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AiEquipmentProposal? _parseProposal(dynamic rawProposal) {
|
||||||
|
if (rawProposal == null || rawProposal is! Map<String, dynamic>) return null;
|
||||||
|
|
||||||
|
final proposalItems = <AiEquipmentProposalItem>[];
|
||||||
|
final eventEquipmentList = <EventEquipment>[];
|
||||||
|
// legacy containerIds variable removed (we now use containersMeta)
|
||||||
|
|
||||||
|
final rawItems = rawProposal['items'];
|
||||||
|
if (rawItems is List) {
|
||||||
|
for (final rawItem in rawItems) {
|
||||||
|
if (rawItem is! Map) continue;
|
||||||
|
final item = Map<String, dynamic>.from(rawItem);
|
||||||
|
|
||||||
|
final equipmentId = item['equipmentId']?.toString().trim() ?? '';
|
||||||
|
final quantity = int.tryParse(item['quantity']?.toString() ?? '1') ?? 1;
|
||||||
|
|
||||||
|
if (equipmentId.isEmpty || quantity <= 0) continue;
|
||||||
|
|
||||||
|
final rationale = item['rationale']?.toString().trim() ?? 'Proposition IA';
|
||||||
|
|
||||||
|
proposalItems.add(AiEquipmentProposalItem(
|
||||||
|
equipmentId: equipmentId,
|
||||||
|
quantity: quantity,
|
||||||
|
rationale: rationale,
|
||||||
|
));
|
||||||
|
eventEquipmentList.add(EventEquipment(
|
||||||
|
equipmentId: equipmentId,
|
||||||
|
quantity: quantity,
|
||||||
|
rationale: rationale,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final containersMeta = <AiEquipmentProposalContainer>[];
|
||||||
|
final rawContainers = rawProposal['containers'];
|
||||||
|
if (rawContainers is List) {
|
||||||
|
for (final rawContainer in rawContainers) {
|
||||||
|
if (rawContainer is String) {
|
||||||
|
final cid = rawContainer.toString().trim();
|
||||||
|
if (cid.isNotEmpty) {
|
||||||
|
containersMeta.add(AiEquipmentProposalContainer(containerId: cid, rationale: 'Proposition IA'));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawContainer is! Map) continue;
|
||||||
|
final container = Map<String, dynamic>.from(rawContainer);
|
||||||
|
final containerId = container['containerId']?.toString().trim() ?? '';
|
||||||
|
if (containerId.isEmpty) continue;
|
||||||
|
|
||||||
|
final rationale = container['rationale']?.toString().trim() ?? 'Proposition IA';
|
||||||
|
final equipmentIds = <String>[];
|
||||||
|
final matching = <String>[];
|
||||||
|
final missing = <String>[];
|
||||||
|
|
||||||
|
if (container['equipmentIds'] is List) {
|
||||||
|
for (final v in container['equipmentIds']) {
|
||||||
|
final s = v == null ? null : v.toString().trim();
|
||||||
|
if (s != null && s.isNotEmpty) equipmentIds.add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (container['matchingEquipmentIds'] is List) {
|
||||||
|
for (final v in container['matchingEquipmentIds']) {
|
||||||
|
final s = v == null ? null : v.toString().trim();
|
||||||
|
if (s != null && s.isNotEmpty) matching.add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (container['missingEquipmentIds'] is List) {
|
||||||
|
for (final v in container['missingEquipmentIds']) {
|
||||||
|
final s = v == null ? null : v.toString().trim();
|
||||||
|
if (s != null && s.isNotEmpty) missing.add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final partial = container['partial'] is bool ? container['partial'] as bool : (missing.isNotEmpty);
|
||||||
|
final available = container.containsKey('available') ? (container['available'] is bool ? container['available'] as bool : null) : null;
|
||||||
|
final availabilityDetail = container.containsKey('availabilityDetail') ? container['availabilityDetail'] : null;
|
||||||
|
|
||||||
|
containersMeta.add(AiEquipmentProposalContainer(
|
||||||
|
containerId: containerId,
|
||||||
|
rationale: rationale,
|
||||||
|
equipmentIds: equipmentIds,
|
||||||
|
matchingEquipmentIds: matching,
|
||||||
|
missingEquipmentIds: missing,
|
||||||
|
partial: partial,
|
||||||
|
available: available,
|
||||||
|
availabilityDetail: availabilityDetail,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proposalItems.isEmpty && containersMeta.isEmpty) return null;
|
||||||
|
|
||||||
|
return AiEquipmentProposal(
|
||||||
|
summary: rawProposal['summary']?.toString().trim().isNotEmpty == true
|
||||||
|
? rawProposal['summary'].toString().trim()
|
||||||
|
: 'Proposition matériel générée automatiquement.',
|
||||||
|
items: proposalItems,
|
||||||
|
asEventEquipment: eventEquipmentList,
|
||||||
|
containers: containersMeta,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -553,6 +553,156 @@ class DataService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recherche des équipements pour l'assistant IA avec fallback paginé.
|
||||||
|
Future<List<Map<String, dynamic>>> searchEquipmentsForAssistant({
|
||||||
|
required String query,
|
||||||
|
int limit = 12,
|
||||||
|
}) async {
|
||||||
|
final normalizedQuery = query.trim();
|
||||||
|
if (normalizedQuery.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final quickResults = await quickSearch(
|
||||||
|
normalizedQuery,
|
||||||
|
limit: limit,
|
||||||
|
includeEquipments: true,
|
||||||
|
includeContainers: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final equipmentResults = quickResults
|
||||||
|
.where((item) =>
|
||||||
|
(item['type']?.toString().toLowerCase() ?? '') == 'equipment')
|
||||||
|
.map(_normalizeAssistantEquipment)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (equipmentResults.isNotEmpty) {
|
||||||
|
return equipmentResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
final paginated = await getEquipmentsPaginated(
|
||||||
|
limit: limit,
|
||||||
|
searchQuery: normalizedQuery,
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
);
|
||||||
|
|
||||||
|
final equipments =
|
||||||
|
paginated['equipments'] as List<Map<String, dynamic>>? ?? [];
|
||||||
|
return equipments.map(_normalizeAssistantEquipment).toList();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[DataService] Error in searchEquipmentsForAssistant', e);
|
||||||
|
throw Exception('Erreur lors de la recherche de matériel: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie la disponibilité d'un équipement dans un format normalisé pour l'IA.
|
||||||
|
Future<Map<String, dynamic>> checkEquipmentAvailabilityForAssistant({
|
||||||
|
required String equipmentId,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
String? excludeEventId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final result = await checkEquipmentAvailability(
|
||||||
|
equipmentId: equipmentId,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
excludeEventId: excludeEventId,
|
||||||
|
);
|
||||||
|
|
||||||
|
final available = result['available'] as bool? ?? true;
|
||||||
|
final conflicts = (result['conflicts'] as List<dynamic>? ?? const [])
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map((conflict) {
|
||||||
|
final eventData =
|
||||||
|
conflict['eventData'] as Map<String, dynamic>? ?? const {};
|
||||||
|
final eventName =
|
||||||
|
(eventData['Name'] ?? conflict['eventName'] ?? '').toString();
|
||||||
|
return {
|
||||||
|
'eventId': conflict['eventId']?.toString() ?? '',
|
||||||
|
'eventName': eventName,
|
||||||
|
'overlapDays': conflict['overlapDays'] as int? ?? 0,
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return {
|
||||||
|
'equipmentId': equipmentId,
|
||||||
|
'available': available,
|
||||||
|
'conflictCount': conflicts.length,
|
||||||
|
'conflicts': conflicts,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error(
|
||||||
|
'[DataService] Error in checkEquipmentAvailabilityForAssistant', e);
|
||||||
|
throw Exception('Erreur lors de la vérification de disponibilité: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne des événements passés, idéalement filtrés par type d'événement.
|
||||||
|
Future<List<Map<String, dynamic>>> getPastEventsForAssistant({
|
||||||
|
String? eventTypeId,
|
||||||
|
int limit = 10,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final events = eventTypeId != null && eventTypeId.isNotEmpty
|
||||||
|
? await getEventsByEventType(eventTypeId)
|
||||||
|
: (await getEvents())['events'] as List<Map<String, dynamic>>? ?? [];
|
||||||
|
|
||||||
|
final pastEvents = events.where((event) {
|
||||||
|
final endDate = _parseEventDate(event['EndDateTime']);
|
||||||
|
return endDate != null && endDate.isBefore(now);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
pastEvents.sort((a, b) {
|
||||||
|
final aDate = _parseEventDate(a['StartDateTime']) ??
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
final bDate = _parseEventDate(b['StartDateTime']) ??
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(0);
|
||||||
|
return bDate.compareTo(aDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
return pastEvents.take(limit).map((event) {
|
||||||
|
final assignedEquipment =
|
||||||
|
event['assignedEquipment'] as List<dynamic>? ?? const [];
|
||||||
|
return {
|
||||||
|
'id': event['id']?.toString() ?? '',
|
||||||
|
'name': (event['Name'] ?? '').toString(),
|
||||||
|
'startDate': event['StartDateTime']?.toString() ?? '',
|
||||||
|
'endDate': event['EndDateTime']?.toString() ?? '',
|
||||||
|
'assignedEquipment': assignedEquipment,
|
||||||
|
'assignedEquipmentCount': assignedEquipment.length,
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[DataService] Error in getPastEventsForAssistant', e);
|
||||||
|
throw Exception(
|
||||||
|
'Erreur lors de la récupération des événements passés: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _normalizeAssistantEquipment(Map<String, dynamic> item) {
|
||||||
|
return {
|
||||||
|
'id': (item['id'] ?? '').toString(),
|
||||||
|
'name': (item['name'] ?? item['id'] ?? '').toString(),
|
||||||
|
'category': (item['category'] ?? '').toString(),
|
||||||
|
'status': (item['status'] ?? '').toString(),
|
||||||
|
'brand': item['brand']?.toString(),
|
||||||
|
'model': item['model']?.toString(),
|
||||||
|
'availableQuantity': item['availableQuantity'],
|
||||||
|
'totalQuantity': item['totalQuantity'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _parseEventDate(dynamic rawValue) {
|
||||||
|
if (rawValue is String) {
|
||||||
|
return DateTime.tryParse(rawValue);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// USER - Current User
|
// USER - Current User
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import 'package:em2rp/providers/container_provider.dart';
|
|||||||
import 'package:em2rp/providers/equipment_provider.dart';
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
import 'package:em2rp/utils/debug_log.dart';
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/utils/id_generator.dart';
|
import 'package:em2rp/utils/id_generator.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
class ContainerFormPage extends StatefulWidget {
|
class ContainerFormPage extends StatefulWidget {
|
||||||
final ContainerModel? container;
|
final ContainerModel? container;
|
||||||
@@ -650,25 +652,86 @@ class _EquipmentSelectorDialog extends StatefulWidget {
|
|||||||
|
|
||||||
class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
EquipmentCategory? _filterCategory;
|
EquipmentCategory? _filterCategory;
|
||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
late Set<String> _tempSelectedIds;
|
late Set<String> _tempSelectedIds;
|
||||||
late final Future<void> _loadingFuture;
|
|
||||||
|
final List<EquipmentModel> _paginatedEquipments = [];
|
||||||
|
bool _isLoadingMore = false;
|
||||||
|
bool _hasMoreEquipments = true;
|
||||||
|
String? _lastEquipmentId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Créer une copie temporaire des IDs sélectionnés
|
// Créer une copie temporaire des IDs sélectionnés
|
||||||
_tempSelectedIds = Set<String>.from(widget.selectedIds);
|
_tempSelectedIds = Set<String>.from(widget.selectedIds);
|
||||||
_loadingFuture = widget.equipmentProvider.loadEquipments();
|
_scrollController.addListener(_onScroll);
|
||||||
|
_loadNextPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (_isLoadingMore) return;
|
||||||
|
if (_scrollController.hasClients &&
|
||||||
|
_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 300) {
|
||||||
|
if (_hasMoreEquipments) {
|
||||||
|
_loadNextPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadNextPage() async {
|
||||||
|
if (_isLoadingMore || !_hasMoreEquipments) return;
|
||||||
|
setState(() => _isLoadingMore = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _dataService.getEquipmentsPaginated(
|
||||||
|
limit: 50,
|
||||||
|
startAfter: _lastEquipmentId,
|
||||||
|
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
||||||
|
category: _filterCategory != null ? equipmentCategoryToString(_filterCategory!) : null,
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
);
|
||||||
|
|
||||||
|
final newEquipments = (result['equipments'] as List<dynamic>)
|
||||||
|
.map((data) => EquipmentModel.fromMap(data as Map<String, dynamic>, data['id'] as String))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_paginatedEquipments.addAll(newEquipments);
|
||||||
|
_hasMoreEquipments = result['hasMore'] as bool? ?? false;
|
||||||
|
_lastEquipmentId = result['lastVisible'] as String?;
|
||||||
|
_isLoadingMore = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoadingMore = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reloadData() async {
|
||||||
|
setState(() {
|
||||||
|
_paginatedEquipments.clear();
|
||||||
|
_lastEquipmentId = null;
|
||||||
|
_hasMoreEquipments = true;
|
||||||
|
});
|
||||||
|
await _loadNextPage();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Dialog(
|
return Dialog(
|
||||||
@@ -718,6 +781,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_searchQuery = '';
|
_searchQuery = '';
|
||||||
});
|
});
|
||||||
|
_reloadData();
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
@@ -726,6 +790,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_searchQuery = value;
|
_searchQuery = value;
|
||||||
});
|
});
|
||||||
|
_reloadData();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -743,6 +808,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_filterCategory = null;
|
_filterCategory = null;
|
||||||
});
|
});
|
||||||
|
_reloadData();
|
||||||
},
|
},
|
||||||
selectedColor: AppColors.rouge,
|
selectedColor: AppColors.rouge,
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
@@ -761,6 +827,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_filterCategory = selected ? category : null;
|
_filterCategory = selected ? category : null;
|
||||||
});
|
});
|
||||||
|
_reloadData();
|
||||||
},
|
},
|
||||||
selectedColor: AppColors.rouge,
|
selectedColor: AppColors.rouge,
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
@@ -780,7 +847,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.rouge.withOpacity(0.1),
|
color: AppColors.rouge.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -798,48 +865,22 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
|
|
||||||
// Liste des équipements
|
// Liste des équipements
|
||||||
Expanded(
|
Expanded(
|
||||||
child: FutureBuilder<void>(
|
child: _paginatedEquipments.isEmpty && !_isLoadingMore
|
||||||
future: _loadingFuture,
|
? const Center(child: Text('Aucun équipement trouvé'))
|
||||||
builder: (context, snapshot) {
|
: ListView.builder(
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
controller: _scrollController,
|
||||||
return const Center(child: CircularProgressIndicator());
|
itemCount: _paginatedEquipments.length + (_isLoadingMore ? 1 : 0),
|
||||||
}
|
|
||||||
|
|
||||||
if (snapshot.hasError) {
|
|
||||||
return Center(child: Text('Erreur: ${snapshot.error}'));
|
|
||||||
}
|
|
||||||
|
|
||||||
var equipment = List<EquipmentModel>.from(
|
|
||||||
widget.equipmentProvider.allEquipment,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filtrer par catégorie
|
|
||||||
if (_filterCategory != null) {
|
|
||||||
equipment = equipment
|
|
||||||
.where((e) => e.category == _filterCategory)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtrer par recherche
|
|
||||||
if (_searchQuery.isNotEmpty) {
|
|
||||||
final query = _searchQuery.toLowerCase();
|
|
||||||
equipment = equipment.where((e) {
|
|
||||||
return e.id.toLowerCase().contains(query) ||
|
|
||||||
(e.brand?.toLowerCase().contains(query) ?? false) ||
|
|
||||||
(e.model?.toLowerCase().contains(query) ?? false);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (equipment.isEmpty) {
|
|
||||||
return const Center(
|
|
||||||
child: Text('Aucun équipement trouvé'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: equipment.length,
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = equipment[index];
|
if (index == _paginatedEquipments.length) {
|
||||||
|
return const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(8.0),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final item = _paginatedEquipments[index];
|
||||||
final isSelected = _tempSelectedIds.contains(item.id);
|
final isSelected = _tempSelectedIds.contains(item.id);
|
||||||
|
|
||||||
return CheckboxListTile(
|
return CheckboxListTile(
|
||||||
@@ -879,8 +920,6 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
activeColor: AppColors.rouge,
|
activeColor: AppColors.rouge,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -163,11 +163,11 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _identifierController,
|
controller: _identifierController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Identifiant *',
|
labelText: 'Identifiant (Laissez vide pour auto-génération) *',
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
prefixIcon: const Icon(Icons.tag),
|
prefixIcon: const Icon(Icons.tag),
|
||||||
hintText: isEditing ? null : 'Laissez vide pour générer automatiquement',
|
hintText: isEditing ? null : 'Auto-attribué par défaut',
|
||||||
helperText: isEditing ? 'Non modifiable' : 'Format auto: {Marque4Chars}_{Modèle}',
|
helperText: isEditing ? 'Non modifiable' : 'Génération auto recommandée basée sur Marque/Modèle',
|
||||||
),
|
),
|
||||||
enabled: !isEditing,
|
enabled: !isEditing,
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final success = await _controller.submitForm(context, existingEvent: widget.event);
|
final success =
|
||||||
|
await _controller.submitForm(context, existingEvent: widget.event);
|
||||||
if (success && mounted) {
|
if (success && mounted) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
@@ -158,21 +159,25 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(isEditMode ? 'Modifier un événement' : 'Créer un événement'),
|
title: Text(
|
||||||
|
isEditMode ? 'Modifier un événement' : 'Créer un événement'),
|
||||||
),
|
),
|
||||||
body: Center(
|
body: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: (isMobile
|
child: (isMobile
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16, vertical: 12),
|
||||||
child: _buildFormContent(isMobile),
|
child: _buildFormContent(isMobile),
|
||||||
)
|
)
|
||||||
: Card(
|
: Card(
|
||||||
elevation: 6,
|
elevation: 6,
|
||||||
margin: const EdgeInsets.all(24),
|
margin: const EdgeInsets.all(24),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(18)),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 32, vertical: 32),
|
||||||
child: _buildFormContent(isMobile),
|
child: _buildFormContent(isMobile),
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
@@ -186,15 +191,6 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
Widget _buildFormContent(bool isMobile) {
|
Widget _buildFormContent(bool isMobile) {
|
||||||
return Consumer<EventFormController>(
|
return Consumer<EventFormController>(
|
||||||
builder: (context, controller, child) {
|
builder: (context, controller, child) {
|
||||||
// Trouver le nom du type d'événement pour le passer au sélecteur d'options
|
|
||||||
final selectedEventTypeIndex = controller.selectedEventTypeId != null
|
|
||||||
? controller.eventTypes.indexWhere((et) => et.id == controller.selectedEventTypeId)
|
|
||||||
: -1;
|
|
||||||
final selectedEventType = selectedEventTypeIndex != -1
|
|
||||||
? controller.eventTypes[selectedEventTypeIndex]
|
|
||||||
: null;
|
|
||||||
final selectedEventTypeName = selectedEventType?.name;
|
|
||||||
|
|
||||||
return Form(
|
return Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -209,18 +205,22 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
selectedEventTypeId: controller.selectedEventTypeId,
|
selectedEventTypeId: controller.selectedEventTypeId,
|
||||||
startDateTime: controller.startDateTime,
|
startDateTime: controller.startDateTime,
|
||||||
endDateTime: controller.endDateTime,
|
endDateTime: controller.endDateTime,
|
||||||
onEventTypeChanged: (typeId) => controller.onEventTypeChanged(typeId, context),
|
onEventTypeChanged: (typeId) =>
|
||||||
|
controller.onEventTypeChanged(typeId, context),
|
||||||
onStartDateTimeChanged: controller.setStartDateTime,
|
onStartDateTimeChanged: controller.setStartDateTime,
|
||||||
onEndDateTimeChanged: controller.setEndDateTime,
|
onEndDateTimeChanged: controller.setEndDateTime,
|
||||||
onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
|
onAnyFieldChanged:
|
||||||
|
() {}, // Géré automatiquement par le contrôleur
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
OptionSelectorWidget(
|
OptionSelectorWidget(
|
||||||
eventType: controller.selectedEventTypeId, // Utilise l'ID au lieu du nom
|
eventType: controller
|
||||||
|
.selectedEventTypeId, // Utilise l'ID au lieu du nom
|
||||||
selectedOptions: controller.selectedOptions,
|
selectedOptions: controller.selectedOptions,
|
||||||
onChanged: controller.setSelectedOptions,
|
onChanged: controller.setSelectedOptions,
|
||||||
onRemove: (optionId) {
|
onRemove: (optionId) {
|
||||||
final newOptions = List<Map<String, dynamic>>.from(controller.selectedOptions);
|
final newOptions = List<Map<String, dynamic>>.from(
|
||||||
|
controller.selectedOptions);
|
||||||
newOptions.removeWhere((o) => o['id'] == optionId);
|
newOptions.removeWhere((o) => o['id'] == optionId);
|
||||||
controller.setSelectedOptions(newOptions);
|
controller.setSelectedOptions(newOptions);
|
||||||
},
|
},
|
||||||
@@ -236,6 +236,7 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
endDate: controller.endDateTime,
|
endDate: controller.endDateTime,
|
||||||
onChanged: controller.setAssignedEquipment,
|
onChanged: controller.setAssignedEquipment,
|
||||||
eventId: widget.event?.id,
|
eventId: widget.event?.id,
|
||||||
|
eventTypeId: controller.selectedEventTypeId,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
EventDetailsSection(
|
EventDetailsSection(
|
||||||
@@ -247,7 +248,8 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
contactEmailController: controller.contactEmailController,
|
contactEmailController: controller.contactEmailController,
|
||||||
contactPhoneController: controller.contactPhoneController,
|
contactPhoneController: controller.contactPhoneController,
|
||||||
isMobile: isMobile,
|
isMobile: isMobile,
|
||||||
onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
|
onAnyFieldChanged:
|
||||||
|
() {}, // Géré automatiquement par le contrôleur
|
||||||
),
|
),
|
||||||
EventStaffAndDocumentsSection(
|
EventStaffAndDocumentsSection(
|
||||||
allUsers: controller.allUsers,
|
allUsers: controller.allUsers,
|
||||||
@@ -290,9 +292,10 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSubmit: _submit,
|
onSubmit: _submit,
|
||||||
onSetConfirmed: !isEditMode ? () {
|
onSetConfirmed: !isEditMode ? () {} : null,
|
||||||
} : null,
|
onDelete: isEditMode
|
||||||
onDelete: isEditMode ? _deleteEvent : null, // Ajout du callback de suppression
|
? _deleteEvent
|
||||||
|
: null, // Ajout du callback de suppression
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,755 @@
|
|||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
import 'package:em2rp/services/ai_equipment_assistant_service.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// Résultat retourné par le dialog après confirmation de la proposition IA.
|
||||||
|
class AiProposalResult {
|
||||||
|
final List<EventEquipment> equipment;
|
||||||
|
final List<String> containerIds;
|
||||||
|
|
||||||
|
const AiProposalResult({
|
||||||
|
required this.equipment,
|
||||||
|
required this.containerIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AiEquipmentAssistantDialog extends StatefulWidget {
|
||||||
|
final DateTime startDate;
|
||||||
|
final DateTime endDate;
|
||||||
|
final String? eventTypeId;
|
||||||
|
final String? excludeEventId;
|
||||||
|
final List<EventEquipment> currentAssignedEquipment;
|
||||||
|
|
||||||
|
const AiEquipmentAssistantDialog({
|
||||||
|
super.key,
|
||||||
|
required this.startDate,
|
||||||
|
required this.endDate,
|
||||||
|
required this.currentAssignedEquipment,
|
||||||
|
this.eventTypeId,
|
||||||
|
this.excludeEventId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AiEquipmentAssistantDialog> createState() =>
|
||||||
|
_AiEquipmentAssistantDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AiEquipmentAssistantDialogState
|
||||||
|
extends State<AiEquipmentAssistantDialog> {
|
||||||
|
final TextEditingController _messageController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final ScrollController _proposalScrollController = ScrollController();
|
||||||
|
final List<_AssistantChatMessage> _messages = [];
|
||||||
|
|
||||||
|
late final AiEquipmentAssistantService _assistantService;
|
||||||
|
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
AiEquipmentProposal? _latestProposal;
|
||||||
|
late List<EventEquipment> _workingEquipment;
|
||||||
|
AiEquipmentDocument? _selectedDocument;
|
||||||
|
List<String> _sessionLogs = [];
|
||||||
|
Set<String> _selectedContainerIds = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_assistantService = AiEquipmentAssistantService();
|
||||||
|
_workingEquipment = List<EventEquipment>.from(widget.currentAssignedEquipment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_messageController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
_proposalScrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isChatEmpty => _messages.isEmpty;
|
||||||
|
|
||||||
|
String get _actionButtonLabel {
|
||||||
|
return _isChatEmpty ? 'Generer la liste automatiquement' : 'Envoyer';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendMessage() async {
|
||||||
|
if (_isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final rawInput = _messageController.text.trim();
|
||||||
|
final isAutoMode = _isChatEmpty;
|
||||||
|
final userMessage = isAutoMode
|
||||||
|
? (rawInput.isNotEmpty
|
||||||
|
? rawInput
|
||||||
|
: 'Genere automatiquement une proposition de materiel pour cet evenement.')
|
||||||
|
: rawInput;
|
||||||
|
|
||||||
|
if (userMessage.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_messageController.clear();
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = null;
|
||||||
|
_messages.add(_AssistantChatMessage.user(userMessage));
|
||||||
|
if (_selectedDocument != null) {
|
||||||
|
_messages.add(_AssistantChatMessage.user('[Document joint : ${_selectedDocument!.fileName ?? "Document"}]'));
|
||||||
|
}
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
_scrollToBottom();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final documentToSend = _selectedDocument;
|
||||||
|
_selectedDocument = null; // Clear after sending
|
||||||
|
final response = await _assistantService
|
||||||
|
.generateProposal(
|
||||||
|
startDate: widget.startDate,
|
||||||
|
endDate: widget.endDate,
|
||||||
|
eventTypeId: widget.eventTypeId,
|
||||||
|
excludeEventId: widget.excludeEventId,
|
||||||
|
currentAssignedEquipment: widget.currentAssignedEquipment,
|
||||||
|
workingProposalEquipment: _workingEquipment,
|
||||||
|
userMessage: userMessage,
|
||||||
|
document: documentToSend,
|
||||||
|
history: _messages
|
||||||
|
.map((message) => AiAssistantChatTurn(
|
||||||
|
isUser: message.isUser, text: message.text))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_messages
|
||||||
|
.add(_AssistantChatMessage.assistant(response.assistantMessage));
|
||||||
|
_latestProposal = response.proposal;
|
||||||
|
if (response.proposal != null) {
|
||||||
|
_workingEquipment = List<EventEquipment>.from(
|
||||||
|
response.proposal!.asEventEquipment,
|
||||||
|
);
|
||||||
|
// Préselectionner les containers non partiels
|
||||||
|
_selectedContainerIds = {
|
||||||
|
for (final c in response.proposal!.containers)
|
||||||
|
if (!c.partial) c.containerId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_sessionLogs.addAll(response.debugLogs);
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
_scrollToBottom();
|
||||||
|
} on FormatException catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_errorMessage = 'Reponse IA invalide: ${error.message}';
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_errorMessage = 'Erreur IA: $error';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollToBottom() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!_scrollController.hasClients) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_scrollController.animateTo(
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickDocument() async {
|
||||||
|
try {
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['pdf', 'txt', 'jpg', 'jpeg', 'png'],
|
||||||
|
withData: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null && result.files.isNotEmpty) {
|
||||||
|
final file = result.files.first;
|
||||||
|
if (file.bytes != null) {
|
||||||
|
final base64String = base64Encode(file.bytes!);
|
||||||
|
String mimeType = 'application/octet-stream';
|
||||||
|
if (file.extension == 'pdf') mimeType = 'application/pdf';
|
||||||
|
else if (file.extension == 'txt') mimeType = 'text/plain';
|
||||||
|
else if (file.extension == 'jpg' || file.extension == 'jpeg') mimeType = 'image/jpeg';
|
||||||
|
else if (file.extension == 'png') mimeType = 'image/png';
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_selectedDocument = AiEquipmentDocument(
|
||||||
|
base64Data: base64String,
|
||||||
|
mimeType: mimeType,
|
||||||
|
fileName: file.name,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Erreur lors de la selection du document : $e';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showLogsDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Logs de l\'IA'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: _sessionLogs.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final log = _sessionLogs[index];
|
||||||
|
final isError = log.startsWith('[ERROR]');
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Text(
|
||||||
|
log,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
color: isError ? Colors.red : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
final fullLogs = _sessionLogs.join('\n');
|
||||||
|
Clipboard.setData(ClipboardData(text: fullLogs));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Logs copiés dans le presse-papiers')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Copier tout'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Fermer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dialog(
|
||||||
|
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 760,
|
||||||
|
height: 640,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
AppBar(
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
title: const Text('(BETA) Assistant IA Logisticien'),
|
||||||
|
actions: [
|
||||||
|
if (_sessionLogs.isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.bug_report),
|
||||||
|
tooltip: 'Voir les logs',
|
||||||
|
onPressed: _showLogsDialog,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed:
|
||||||
|
_isLoading ? null : () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: _messages.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final message = _messages[index];
|
||||||
|
return _buildMessageBubble(message);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isLoading)
|
||||||
|
const Padding(
|
||||||
|
padding:
|
||||||
|
EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: const Text(
|
||||||
|
'Generation en cours... verification du materiel et disponibilites. (Cela peut prendre jusqu\'a une minute en cas de forte affluence)',
|
||||||
|
textAlign: TextAlign.left,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_errorMessage != null)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: Colors.red.shade200),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_errorMessage!,
|
||||||
|
style: TextStyle(color: Colors.red.shade800),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_latestProposal != null)
|
||||||
|
_buildProposalSummary(_latestProposal!),
|
||||||
|
if (_selectedDocument != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 16, right: 16, top: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.attach_file, color: Colors.blue, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_selectedDocument!.fileName ?? 'Document joint',
|
||||||
|
style: const TextStyle(color: Colors.blue, fontWeight: FontWeight.w500),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedDocument = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
tooltip: 'Retirer le document',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.attach_file),
|
||||||
|
onPressed: _isLoading ? null : _pickDocument,
|
||||||
|
tooltip: 'Joindre un devis ou document',
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _messageController,
|
||||||
|
enabled: !_isLoading,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText:
|
||||||
|
'Precisez votre besoin (style, jauge, contraintes...)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _sendMessage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isLoading ? null : _sendMessage,
|
||||||
|
child: Text(_actionButtonLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessageBubble(_AssistantChatMessage message) {
|
||||||
|
final bubbleColor = message.isUser ? Colors.blue.shade600 : Colors.white;
|
||||||
|
final textColor = message.isUser ? Colors.white : Colors.black87;
|
||||||
|
|
||||||
|
return Align(
|
||||||
|
alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bubbleColor,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: const Radius.circular(16),
|
||||||
|
topRight: const Radius.circular(16),
|
||||||
|
bottomLeft: Radius.circular(message.isUser ? 16 : 4),
|
||||||
|
bottomRight: Radius.circular(message.isUser ? 4 : 16),
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
border:
|
||||||
|
message.isUser ? null : Border.all(color: Colors.grey.shade200),
|
||||||
|
),
|
||||||
|
child: message.isUser
|
||||||
|
? Text(message.text, style: TextStyle(color: textColor))
|
||||||
|
: _buildAssistantMessageContent(message.text),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAssistantMessageContent(String text) {
|
||||||
|
// Si le message semble structuré par l'IA avec nos nouvelles règles
|
||||||
|
if (text.contains('Matériel ajouté :') || text.contains('Matériel non trouvé')) {
|
||||||
|
final sections = text.split('\n\n');
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: sections.map((section) {
|
||||||
|
final isAdded = section.contains('Matériel ajouté :');
|
||||||
|
final isMissing = section.contains('Matériel non trouvé');
|
||||||
|
|
||||||
|
if (isAdded) {
|
||||||
|
return _buildStatusSection(
|
||||||
|
title: section.split('\n').first,
|
||||||
|
content: section.split('\n').skip(1).join('\n'),
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
color: Colors.green.shade700,
|
||||||
|
bgColor: Colors.green.shade50,
|
||||||
|
);
|
||||||
|
} else if (isMissing) {
|
||||||
|
return _buildStatusSection(
|
||||||
|
title: section.split('\n').first,
|
||||||
|
content: section.split('\n').skip(1).join('\n'),
|
||||||
|
icon: Icons.warning_amber_rounded,
|
||||||
|
color: Colors.orange.shade800,
|
||||||
|
bgColor: Colors.orange.shade50,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8.0),
|
||||||
|
child: Text(section),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Text(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusSection({
|
||||||
|
required String title,
|
||||||
|
required String content,
|
||||||
|
required IconData icon,
|
||||||
|
required Color color,
|
||||||
|
required Color bgColor,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bgColor,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: color),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title.replaceAll(':', '').trim(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (content.trim().isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
content.trim(),
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade800),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmProposal({bool excludeAlternatives = false}) {
|
||||||
|
if (_latestProposal == null) return;
|
||||||
|
|
||||||
|
List<EventEquipment> equipment = List.from(_latestProposal!.asEventEquipment);
|
||||||
|
// Ne renvoyer que les containerIds sélectionnés (par défaut les containers complets)
|
||||||
|
final List<String> containerIds = _selectedContainerIds.isNotEmpty
|
||||||
|
? _selectedContainerIds.toList()
|
||||||
|
: List.from(_latestProposal!.containerIds);
|
||||||
|
|
||||||
|
if (excludeAlternatives) {
|
||||||
|
// On utilise la liste des items d'origine pour savoir lesquels exclure
|
||||||
|
// car ils contiennent le champ rationale (avant conversion en EventEquipment)
|
||||||
|
final idsToExclude = _latestProposal!.items
|
||||||
|
.where((item) {
|
||||||
|
final rationale = item.rationale.toLowerCase();
|
||||||
|
return rationale.contains('alternative') ||
|
||||||
|
rationale.contains('remplacement') ||
|
||||||
|
rationale.contains('indisponible');
|
||||||
|
})
|
||||||
|
.map((item) => item.equipmentId)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
equipment = equipment.where((eq) => !idsToExclude.contains(eq.equipmentId)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Navigator.of(context).pop(
|
||||||
|
AiProposalResult(
|
||||||
|
equipment: equipment,
|
||||||
|
containerIds: containerIds,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProposalSummary(AiEquipmentProposal proposal) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
constraints: const BoxConstraints(maxHeight: 280),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.assignment_turned_in, color: Colors.indigo),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Récapitulatif de la proposition IA',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.indigo,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Flexible(
|
||||||
|
child: Scrollbar(
|
||||||
|
controller: _proposalScrollController,
|
||||||
|
thumbVisibility: true,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
controller: _proposalScrollController,
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
proposal.summary,
|
||||||
|
style: const TextStyle(fontStyle: FontStyle.italic),
|
||||||
|
),
|
||||||
|
if (proposal.items.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Matériel individuel :',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
...proposal.items.map((item) {
|
||||||
|
final isAlt = item.rationale.toLowerCase().contains('alternative') || item.rationale.toLowerCase().contains('remplacement');
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 6, left: 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isAlt ? Icons.swap_horiz : Icons.add_circle_outline,
|
||||||
|
size: 14,
|
||||||
|
color: isAlt ? Colors.orange : Colors.indigo,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${item.equipmentId} x${item.quantity}',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
if (proposal.containers.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'Fly-cases & Boîtes :',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
...proposal.containers.map((c) {
|
||||||
|
final isPartial = c.partial;
|
||||||
|
final isSelected = _selectedContainerIds.contains(c.containerId);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 6, left: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.inventory_2_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: c.available == false ? Colors.red : Colors.indigo,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text('${c.containerId} ${c.rationale.isNotEmpty ? "- ${c.rationale}" : ""}', style: const TextStyle(fontWeight: FontWeight.w500))),
|
||||||
|
if (c.available == false)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8.0),
|
||||||
|
child: Icon(Icons.block, color: Colors.red.shade700, size: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (isPartial) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('Contenu partiel : ${c.matchingEquipmentIds.length}/${c.equipmentIds.length} items utilisés.', style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (isPartial)
|
||||||
|
Checkbox(
|
||||||
|
value: isSelected,
|
||||||
|
onChanged: (v) {
|
||||||
|
setState(() {
|
||||||
|
if (v == true) _selectedContainerIds.add(c.containerId);
|
||||||
|
else _selectedContainerIds.remove(c.containerId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _isLoading ? null : () => _confirmProposal(),
|
||||||
|
icon: const Icon(Icons.check),
|
||||||
|
label: const Text('Tout ajouter'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.indigo,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _isLoading ? null : () => _confirmProposal(excludeAlternatives: true),
|
||||||
|
icon: const Icon(Icons.filter_list_off),
|
||||||
|
label: const Text('Ajouter sans alternatives'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.indigo,
|
||||||
|
side: const BorderSide(color: Colors.indigo),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AssistantChatMessage {
|
||||||
|
final bool isUser;
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
const _AssistantChatMessage._({required this.isUser, required this.text});
|
||||||
|
|
||||||
|
factory _AssistantChatMessage.user(String text) {
|
||||||
|
return _AssistantChatMessage._(isUser: true, text: text);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory _AssistantChatMessage.assistant(String text) {
|
||||||
|
return _AssistantChatMessage._(isUser: false, text: text);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import 'package:em2rp/providers/equipment_provider.dart';
|
|||||||
import 'package:em2rp/providers/container_provider.dart';
|
import 'package:em2rp/providers/container_provider.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart';
|
import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart';
|
||||||
|
import 'package:em2rp/views/widgets/event_form/ai_equipment_assistant_dialog.dart';
|
||||||
|
|
||||||
/// Section pour afficher et gérer le matériel assigné à un événement
|
/// Section pour afficher et gérer le matériel assigné à un événement
|
||||||
class EventAssignedEquipmentSection extends StatefulWidget {
|
class EventAssignedEquipmentSection extends StatefulWidget {
|
||||||
@@ -17,6 +18,7 @@ class EventAssignedEquipmentSection extends StatefulWidget {
|
|||||||
final DateTime? endDate;
|
final DateTime? endDate;
|
||||||
final Function(List<EventEquipment>, List<String>) onChanged;
|
final Function(List<EventEquipment>, List<String>) onChanged;
|
||||||
final String? eventId; // Pour exclure l'événement actuel de la vérification
|
final String? eventId; // Pour exclure l'événement actuel de la vérification
|
||||||
|
final String? eventTypeId;
|
||||||
|
|
||||||
const EventAssignedEquipmentSection({
|
const EventAssignedEquipmentSection({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -26,14 +28,18 @@ class EventAssignedEquipmentSection extends StatefulWidget {
|
|||||||
required this.endDate,
|
required this.endDate,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
this.eventId,
|
this.eventId,
|
||||||
|
this.eventTypeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<EventAssignedEquipmentSection> createState() => _EventAssignedEquipmentSectionState();
|
State<EventAssignedEquipmentSection> createState() =>
|
||||||
|
_EventAssignedEquipmentSectionState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSection> {
|
class _EventAssignedEquipmentSectionState
|
||||||
bool get _canAddMaterial => widget.startDate != null && widget.endDate != null;
|
extends State<EventAssignedEquipmentSection> {
|
||||||
|
bool get _canAddMaterial =>
|
||||||
|
widget.startDate != null && widget.endDate != null;
|
||||||
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;
|
||||||
@@ -61,19 +67,24 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
final equipmentProvider = context.read<EquipmentProvider>();
|
final equipmentProvider = context.read<EquipmentProvider>();
|
||||||
final containerProvider = context.read<ContainerProvider>();
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
|
|
||||||
DebugLog.info('[EventAssignedEquipmentSection] Loading caches from assigned lists');
|
DebugLog.info(
|
||||||
|
'[EventAssignedEquipmentSection] Loading caches from assigned lists');
|
||||||
|
|
||||||
// Toujours partir des données locales du formulaire pour éviter les décalages visuels.
|
// Toujours partir des données locales du formulaire pour éviter les décalages visuels.
|
||||||
final equipmentIds = widget.assignedEquipment.map((eq) => eq.equipmentId).toList();
|
final equipmentIds =
|
||||||
final containers = await containerProvider.getContainersByIds(widget.assignedContainers);
|
widget.assignedEquipment.map((eq) => eq.equipmentId).toList();
|
||||||
|
final containers =
|
||||||
|
await containerProvider.getContainersByIds(widget.assignedContainers);
|
||||||
|
|
||||||
final childEquipmentIds = <String>[];
|
final childEquipmentIds = <String>[];
|
||||||
for (final container in containers) {
|
for (final container in containers) {
|
||||||
childEquipmentIds.addAll(container.equipmentIds);
|
childEquipmentIds.addAll(container.equipmentIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
final allEquipmentIds = <String>{...equipmentIds, ...childEquipmentIds}.toList();
|
final allEquipmentIds =
|
||||||
final equipment = await equipmentProvider.getEquipmentsByIds(allEquipmentIds);
|
<String>{...equipmentIds, ...childEquipmentIds}.toList();
|
||||||
|
final equipment =
|
||||||
|
await equipmentProvider.getEquipmentsByIds(allEquipmentIds);
|
||||||
|
|
||||||
_equipmentCache.clear();
|
_equipmentCache.clear();
|
||||||
_containerCache.clear();
|
_containerCache.clear();
|
||||||
@@ -110,7 +121,9 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
_containerCache[containerId] = container;
|
_containerCache[containerId] = container;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLog.error('[EventAssignedEquipmentSection] Error loading equipment and containers', e);
|
DebugLog.error(
|
||||||
|
'[EventAssignedEquipmentSection] Error loading equipment and containers',
|
||||||
|
e);
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
@@ -138,7 +151,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _processSelection(Map<String, SelectedItem> selection) async {
|
Future<void> _processSelection(Map<String, SelectedItem> selection) async {
|
||||||
DebugLog.info('[EventAssignedEquipmentSection] Processing selection of ${selection.length} items');
|
DebugLog.info(
|
||||||
|
'[EventAssignedEquipmentSection] Processing selection of ${selection.length} items');
|
||||||
|
|
||||||
// Séparer équipements et conteneurs
|
// Séparer équipements et conteneurs
|
||||||
final newEquipment = <EventEquipment>[];
|
final newEquipment = <EventEquipment>[];
|
||||||
@@ -155,23 +169,27 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DebugLog.info('[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
|
DebugLog.info(
|
||||||
|
'[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
|
||||||
|
|
||||||
// 🔧 FIX: Pour chaque container sélectionné, ajouter aussi ses équipements enfants
|
// 🔧 FIX: Pour chaque container sélectionné, ajouter aussi ses équipements enfants
|
||||||
if (newContainers.isNotEmpty) {
|
if (newContainers.isNotEmpty) {
|
||||||
final containerProvider = context.read<ContainerProvider>();
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
final containers = await containerProvider.getContainersByIds(newContainers);
|
final containers =
|
||||||
|
await containerProvider.getContainersByIds(newContainers);
|
||||||
|
|
||||||
for (var container in containers) {
|
for (var container in containers) {
|
||||||
for (var childEquipmentId in container.equipmentIds) {
|
for (var childEquipmentId in container.equipmentIds) {
|
||||||
// Vérifier si l'équipement enfant n'est pas déjà dans la liste
|
// Vérifier si l'équipement enfant n'est pas déjà dans la liste
|
||||||
final existsInNew = newEquipment.any((eq) => eq.equipmentId == childEquipmentId);
|
final existsInNew =
|
||||||
|
newEquipment.any((eq) => eq.equipmentId == childEquipmentId);
|
||||||
if (!existsInNew) {
|
if (!existsInNew) {
|
||||||
newEquipment.add(EventEquipment(
|
newEquipment.add(EventEquipment(
|
||||||
equipmentId: childEquipmentId,
|
equipmentId: childEquipmentId,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
));
|
));
|
||||||
DebugLog.info('[EventAssignedEquipmentSection] Adding child equipment $childEquipmentId from container ${container.id}');
|
DebugLog.info(
|
||||||
|
'[EventAssignedEquipmentSection] Adding child equipment $childEquipmentId from container ${container.id}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,7 +204,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
|
|
||||||
// Pour chaque nouvel équipement
|
// Pour chaque nouvel équipement
|
||||||
for (var eq in newEquipment) {
|
for (var eq in newEquipment) {
|
||||||
final existingIndex = updatedEquipment.indexWhere((e) => e.equipmentId == eq.equipmentId);
|
final existingIndex =
|
||||||
|
updatedEquipment.indexWhere((e) => e.equipmentId == eq.equipmentId);
|
||||||
|
|
||||||
if (existingIndex != -1) {
|
if (existingIndex != -1) {
|
||||||
// L'équipement existe déjà : mettre à jour la quantité
|
// L'équipement existe déjà : mettre à jour la quantité
|
||||||
@@ -215,6 +234,74 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
widget.onChanged(updatedEquipment, updatedContainers);
|
widget.onChanged(updatedEquipment, updatedContainers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openAiAssistantDialog() async {
|
||||||
|
if (widget.startDate == null || widget.endDate == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await showDialog<AiProposalResult>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AiEquipmentAssistantDialog(
|
||||||
|
startDate: widget.startDate!,
|
||||||
|
endDate: widget.endDate!,
|
||||||
|
eventTypeId: widget.eventTypeId,
|
||||||
|
excludeEventId: widget.eventId,
|
||||||
|
currentAssignedEquipment: widget.assignedEquipment,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyAiProposal(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyAiProposal(AiProposalResult result) async {
|
||||||
|
final existingById = {
|
||||||
|
for (final equipment in widget.assignedEquipment)
|
||||||
|
equipment.equipmentId: equipment,
|
||||||
|
};
|
||||||
|
|
||||||
|
final updatedEquipment = result.equipment.map((proposed) {
|
||||||
|
final existing = existingById[proposed.equipmentId];
|
||||||
|
if (existing == null) {
|
||||||
|
return proposed;
|
||||||
|
}
|
||||||
|
return existing.copyWith(quantity: proposed.quantity, rationale: proposed.rationale);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// 🔧 FIX: Pour chaque container ajouté par l'IA, ajouter aussi ses équipements enfants
|
||||||
|
if (result.containerIds.isNotEmpty) {
|
||||||
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
|
final containers = await containerProvider.getContainersByIds(result.containerIds);
|
||||||
|
|
||||||
|
for (var container in containers) {
|
||||||
|
for (var childEquipmentId in container.equipmentIds) {
|
||||||
|
// Vérifier si l'équipement enfant n'est pas déjà dans la liste (ou déjà ajouté par la proposition)
|
||||||
|
final exists = updatedEquipment.any((eq) => eq.equipmentId == childEquipmentId);
|
||||||
|
if (!exists) {
|
||||||
|
updatedEquipment.add(EventEquipment(
|
||||||
|
equipmentId: childEquipmentId,
|
||||||
|
quantity: 1,
|
||||||
|
rationale: 'Inclus dans ${container.id}',
|
||||||
|
));
|
||||||
|
DebugLog.info('[EventAssignedEquipmentSection] AI adding child equipment $childEquipmentId from container ${container.id}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final updatedContainers = [...widget.assignedContainers];
|
||||||
|
for (final containerId in result.containerIds) {
|
||||||
|
if (!updatedContainers.contains(containerId)) {
|
||||||
|
updatedContainers.add(containerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
widget.onChanged(updatedEquipment, updatedContainers);
|
||||||
|
}
|
||||||
|
|
||||||
void _removeEquipment(String equipmentId) {
|
void _removeEquipment(String equipmentId) {
|
||||||
final updated = widget.assignedEquipment
|
final updated = widget.assignedEquipment
|
||||||
.where((eq) => eq.equipmentId != equipmentId)
|
.where((eq) => eq.equipmentId != equipmentId)
|
||||||
@@ -231,9 +318,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
final container = _containerCache[containerId];
|
final container = _containerCache[containerId];
|
||||||
|
|
||||||
// Retirer le conteneur de la liste
|
// Retirer le conteneur de la liste
|
||||||
final updatedContainers = widget.assignedContainers
|
final updatedContainers =
|
||||||
.where((id) => id != containerId)
|
widget.assignedContainers.where((id) => id != containerId).toList();
|
||||||
.toList();
|
|
||||||
|
|
||||||
// 🔧 FIX: Ne supprimer les équipements enfants QUE s'ils ne sont pas dans un autre container
|
// 🔧 FIX: Ne supprimer les équipements enfants QUE s'ils ne sont pas dans un autre container
|
||||||
final updatedEquipment = <EventEquipment>[];
|
final updatedEquipment = <EventEquipment>[];
|
||||||
@@ -252,8 +338,10 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
// 1. Ne sont PAS dans le container supprimé OU
|
// 1. Ne sont PAS dans le container supprimé OU
|
||||||
// 2. Sont dans le container supprimé MAIS aussi dans un autre container
|
// 2. Sont dans le container supprimé MAIS aussi dans un autre container
|
||||||
for (var eq in widget.assignedEquipment) {
|
for (var eq in widget.assignedEquipment) {
|
||||||
final isInRemovedContainer = container.equipmentIds.contains(eq.equipmentId);
|
final isInRemovedContainer =
|
||||||
final isInOtherContainer = equipmentIdsInOtherContainers.contains(eq.equipmentId);
|
container.equipmentIds.contains(eq.equipmentId);
|
||||||
|
final isInOtherContainer =
|
||||||
|
equipmentIdsInOtherContainers.contains(eq.equipmentId);
|
||||||
|
|
||||||
if (!isInRemovedContainer || isInOtherContainer) {
|
if (!isInRemovedContainer || isInOtherContainer) {
|
||||||
updatedEquipment.add(eq);
|
updatedEquipment.add(eq);
|
||||||
@@ -271,7 +359,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
_containerCache.remove(containerId);
|
_containerCache.remove(containerId);
|
||||||
// Nettoyer le cache uniquement pour les équipements effectivement supprimés
|
// Nettoyer le cache uniquement pour les équipements effectivement supprimés
|
||||||
if (container != null) {
|
if (container != null) {
|
||||||
final remainingEquipmentIds = updatedEquipment.map((eq) => eq.equipmentId).toSet();
|
final remainingEquipmentIds =
|
||||||
|
updatedEquipment.map((eq) => eq.equipmentId).toSet();
|
||||||
for (var equipmentId in container.equipmentIds) {
|
for (var equipmentId in container.equipmentIds) {
|
||||||
if (!remainingEquipmentIds.contains(equipmentId)) {
|
if (!remainingEquipmentIds.contains(equipmentId)) {
|
||||||
_equipmentCache.remove(equipmentId);
|
_equipmentCache.remove(equipmentId);
|
||||||
@@ -301,7 +390,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final totalItems = widget.assignedEquipment.length + widget.assignedContainers.length;
|
final totalItems =
|
||||||
|
widget.assignedEquipment.length + widget.assignedContainers.length;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
@@ -350,15 +440,25 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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(
|
ElevatedButton.icon(
|
||||||
onPressed: _canAddMaterial ? _openSelectionDialog : null,
|
onPressed: _canAddMaterial ? _openSelectionDialog : null,
|
||||||
icon: Icon(Icons.add, color: _canAddMaterial ? Colors.white : Colors.grey),
|
icon: Icon(Icons.add,
|
||||||
|
color: _canAddMaterial ? Colors.white : Colors.grey),
|
||||||
label: Text(
|
label: Text(
|
||||||
'Ajouter',
|
'Ajouter',
|
||||||
style: TextStyle(color: _canAddMaterial ? Colors.white : Colors.grey),
|
style: TextStyle(
|
||||||
|
color: _canAddMaterial ? Colors.white : Colors.grey),
|
||||||
),
|
),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: _canAddMaterial ? AppColors.rouge : Colors.grey.shade300,
|
backgroundColor: _canAddMaterial
|
||||||
|
? AppColors.rouge
|
||||||
|
: Colors.grey.shade300,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -512,7 +612,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -537,7 +638,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
color: Colors.grey.shade600,
|
color: Colors.grey.shade600,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
eq.category.getIcon(size: 16, color: eq.category.color),
|
eq.category
|
||||||
|
.getIcon(size: 16, color: eq.category.color),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -562,7 +664,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEquipmentItem(EquipmentModel? equipment, EventEquipment eventEq) {
|
Widget _buildEquipmentItem(
|
||||||
|
EquipmentModel? equipment, EventEquipment eventEq) {
|
||||||
if (equipment == null) {
|
if (equipment == null) {
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
@@ -592,10 +695,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: CircleAvatar(
|
leading: CircleAvatar(
|
||||||
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
|
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
|
||||||
child: equipment.category.getIconForAvatar(
|
child: equipment.category
|
||||||
size: 24,
|
.getIconForAvatar(size: 24, color: equipment.category.color),
|
||||||
color: equipment.category.color
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
equipment.id,
|
equipment.id,
|
||||||
@@ -634,4 +735,3 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "1.1.23",
|
"version": "1.2.1",
|
||||||
"updateUrl": "https://app.em2events.fr",
|
"updateUrl": "https://app.em2events.fr",
|
||||||
"forceUpdate": true,
|
"forceUpdate": true,
|
||||||
"releaseNotes": "Optimisation du lancement de l'application et amélioration de la gestion du cache.",
|
"releaseNotes": "Ajout d'un assistant IA pour la gestion des équipments dans un événement. Il permet de suggérer des equipements selon les informations qui lui sont données (copier un événement similaire, lire un devis, etc.) et de faire des recommandations pour optimiser la préparation d'un événement.",
|
||||||
"timestamp": "2026-05-05T09:52:18.860Z"
|
"timestamp": "2026-05-25T21:50:50.578Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user