Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84c882ac0b |
@@ -34,16 +34,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63
|
||||
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
|
||||
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
|
||||
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
|
||||
version.json,1773324020831,d5cd7334d7c3a990dbff0821b9aaab39129803e306b0d96599b8adc6d4f433a6
|
||||
index.html,1773324025840,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
||||
flutter_service_worker.js,1773324116910,8582e401e070055f59183c207cf7a7e6a9219a50f5089a24a77d91d3ff77dcbc
|
||||
flutter_bootstrap.js,1773324025827,74eaa66055c715df232ee96fc4114d5473f67717278fb4effa38d8b1b362e303
|
||||
assets/FontManifest.json,1773324113335,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
||||
assets/AssetManifest.json,1773324113335,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
|
||||
assets/AssetManifest.bin.json,1773324113335,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
|
||||
assets/AssetManifest.bin,1773324113335,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
|
||||
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1773324115847,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
||||
assets/shaders/ink_sparkle.frag,1773324113551,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
||||
assets/fonts/MaterialIcons-Regular.otf,1773324115852,d1409c3c8050990bdc63a413539d600245a27c9794a053c211299cc86d4f6a5c
|
||||
assets/NOTICES,1773324113339,1d9a08da58db7959b9607f0f1f342f96243af76dc608ed659614d586ec58cd79
|
||||
main.dart.js,1773324112059,bfc66ab7e817db63dee4b996af3dea0629c4c4e87ba91070c15b133ab5104848
|
||||
version.json,1773346314557,fda0011c81b6890abb52de8e160b96b7fa61bd4fbb8c45af2fbecb29d5df708d
|
||||
index.html,1773346319918,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
||||
flutter_service_worker.js,1773346397863,2f92f9c59bdab08ddbc8274db4459302bd6134e3987b0decdb26323a257b0ab7
|
||||
assets/FontManifest.json,1773346394287,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
||||
flutter_bootstrap.js,1773346319903,1a83667573bf9cf4a4a90e3d1631fbc55b97cebfb14c643ddf9d3468bde748ec
|
||||
assets/AssetManifest.json,1773346394287,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
|
||||
assets/AssetManifest.bin.json,1773346394287,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
|
||||
assets/AssetManifest.bin,1773346394287,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
|
||||
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1773346397053,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
||||
assets/shaders/ink_sparkle.frag,1773346394513,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
||||
assets/fonts/MaterialIcons-Regular.otf,1773346397057,d1409c3c8050990bdc63a413539d600245a27c9794a053c211299cc86d4f6a5c
|
||||
assets/NOTICES,1773346394289,1d9a08da58db7959b9607f0f1f342f96243af76dc608ed659614d586ec58cd79
|
||||
main.dart.js,1773346393292,a9b20044339caf5878c0d72b7a45df204e67eab3d4c288b5964d852059c88bdd
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
# Changelog - EM2RP
|
||||
|
||||
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
|
||||
|
||||
## 24/03/2026
|
||||
Fix BUG : Problème de cache avec les équipements non affichés dans le dialog de sélection d'équipement. Amélioration de la gestion du cache pour éviter les problèmes d'affichage.
|
||||
|
||||
## 12/03/2026bis
|
||||
Fix BUG : Ajout equipement à un evenement existant, boutons de modification de statut d'un evenement ne fonctionnaient pas. Refonte legere de la page calendrier.
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@ SMTP_PASS="aL8@Rx8xqFrNij$a"
|
||||
# URL de l'application
|
||||
APP_URL="https://app.em2events.fr"
|
||||
|
||||
GEMINI_API_KEY="AIzaSyBdBdjFLma2pLenmFBlqZHArS4GVF-mclo"
|
||||
|
||||
1225
em2rp/functions/aiEquipmentProposal.js
Normal file
1225
em2rp/functions/aiEquipmentProposal.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ const { Storage } = require('@google-cloud/storage');
|
||||
const auth = require('./utils/auth');
|
||||
const helpers = require('./utils/helpers');
|
||||
const { generateTTS } = require('./generateTTS');
|
||||
const { handleAiEquipmentProposal } = require('./aiEquipmentProposal');
|
||||
|
||||
// Initialisation sécurisée
|
||||
if (!admin.apps.length) {
|
||||
@@ -33,6 +34,13 @@ const httpOptions = {
|
||||
// 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
|
||||
// ============================================================================
|
||||
@@ -4263,3 +4271,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 });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
10
em2rp/functions/package-lock.json
generated
10
em2rp/functions/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "^7.18.0",
|
||||
"@google-cloud/text-to-speech": "^5.4.0",
|
||||
"@google/generative-ai": "^0.21.0",
|
||||
"axios": "^1.13.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"envdot": "^0.0.3",
|
||||
@@ -785,6 +786,15 @@
|
||||
"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": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"dependencies": {
|
||||
"@google-cloud/storage": "^7.18.0",
|
||||
"@google-cloud/text-to-speech": "^5.4.0",
|
||||
"@google/generative-ai": "^0.21.0",
|
||||
"axios": "^1.13.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"envdot": "^0.0.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/// Configuration de la version de l'application
|
||||
class AppVersion {
|
||||
static const String version = '1.1.19';
|
||||
static const String version = '1.1.18';
|
||||
|
||||
/// Retourne la version complète de l'application
|
||||
static String get fullVersion => 'v$version';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
class Env {
|
||||
static const bool isDevelopment = false;
|
||||
static const bool isDevelopment = true;
|
||||
|
||||
// Configuration de l'auto-login en développement
|
||||
static const String devAdminEmail = '';
|
||||
static const String devAdminPassword = '';
|
||||
static const String devAdminEmail = 'paul.fournel@em2events.fr';
|
||||
static const String devAdminPassword = 'Pastis51!';
|
||||
|
||||
// URLs et endpoints
|
||||
static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com';
|
||||
@@ -14,3 +14,4 @@ class Env {
|
||||
// Autres configurations
|
||||
static const int apiTimeout = 30000; // 30 secondes
|
||||
}
|
||||
|
||||
|
||||
178
em2rp/lib/services/ai_equipment_assistant_service.dart
Normal file
178
em2rp/lib/services/ai_equipment_assistant_service.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
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});
|
||||
}
|
||||
|
||||
/// 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,
|
||||
});
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
/// IDs des containers (flight cases) proposés par l'IA.
|
||||
final List<String> containerIds;
|
||||
|
||||
const AiEquipmentProposal({
|
||||
required this.summary,
|
||||
required this.items,
|
||||
required this.asEventEquipment,
|
||||
required this.containerIds,
|
||||
});
|
||||
}
|
||||
|
||||
/// Réponse complète de l'assistant IA (message + proposition optionnelle).
|
||||
class AiEquipmentAssistantResponse {
|
||||
final String assistantMessage;
|
||||
final AiEquipmentProposal? proposal;
|
||||
|
||||
const AiEquipmentAssistantResponse({
|
||||
required this.assistantMessage,
|
||||
this.proposal,
|
||||
});
|
||||
}
|
||||
|
||||
/// 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 [],
|
||||
}) 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;
|
||||
|
||||
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']);
|
||||
|
||||
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,
|
||||
);
|
||||
} 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>[];
|
||||
final containerIds = <String>[];
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
final rawContainers = rawProposal['containers'];
|
||||
if (rawContainers is List) {
|
||||
for (final rawContainer in rawContainers) {
|
||||
if (rawContainer is! Map) continue;
|
||||
final container = Map<String, dynamic>.from(rawContainer);
|
||||
final containerId = container['containerId']?.toString().trim() ?? '';
|
||||
if (containerId.isNotEmpty) {
|
||||
containerIds.add(containerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (proposalItems.isEmpty && containerIds.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,
|
||||
containerIds: containerIds,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,8 @@ class DataService {
|
||||
if (eventTypes == null) return [];
|
||||
return eventTypes.map((e) => e as Map<String, dynamic>).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des types d\'événements: $e');
|
||||
throw Exception(
|
||||
'Erreur lors de la récupération des types d\'événements: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,15 +56,18 @@ class DataService {
|
||||
try {
|
||||
final data = <String, dynamic>{'eventId': eventId};
|
||||
|
||||
if (assignedEquipment != null) data['assignedEquipment'] = assignedEquipment;
|
||||
if (preparationStatus != null) data['preparationStatus'] = preparationStatus;
|
||||
if (assignedEquipment != null)
|
||||
data['assignedEquipment'] = assignedEquipment;
|
||||
if (preparationStatus != null)
|
||||
data['preparationStatus'] = preparationStatus;
|
||||
if (loadingStatus != null) data['loadingStatus'] = loadingStatus;
|
||||
if (unloadingStatus != null) data['unloadingStatus'] = unloadingStatus;
|
||||
if (returnStatus != null) data['returnStatus'] = returnStatus;
|
||||
|
||||
await _apiService.call('updateEventEquipment', data);
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la mise à jour des équipements de l\'événement: $e');
|
||||
throw Exception(
|
||||
'Erreur lors de la mise à jour des équipements de l\'événement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,11 +81,13 @@ class DataService {
|
||||
final data = <String, dynamic>{'equipmentId': equipmentId};
|
||||
|
||||
if (status != null) data['status'] = status;
|
||||
if (availableQuantity != null) data['availableQuantity'] = availableQuantity;
|
||||
if (availableQuantity != null)
|
||||
data['availableQuantity'] = availableQuantity;
|
||||
|
||||
await _apiService.call('updateEquipmentStatusOnly', data);
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la mise à jour du statut de l\'équipement: $e');
|
||||
throw Exception(
|
||||
'Erreur lors de la mise à jour du statut de l\'équipement: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +112,8 @@ class DataService {
|
||||
}
|
||||
|
||||
/// Crée un équipement
|
||||
Future<void> createEquipment(String equipmentId, Map<String, dynamic> data) async {
|
||||
Future<void> createEquipment(
|
||||
String equipmentId, Map<String, dynamic> data) async {
|
||||
try {
|
||||
// S'assurer que l'ID est dans les données
|
||||
final equipmentData = Map<String, dynamic>.from(data);
|
||||
@@ -119,7 +126,8 @@ class DataService {
|
||||
}
|
||||
|
||||
/// Met à jour un équipement
|
||||
Future<void> updateEquipment(String equipmentId, Map<String, dynamic> data) async {
|
||||
Future<void> updateEquipment(
|
||||
String equipmentId, Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _apiService.call('updateEquipment', {
|
||||
'equipmentId': equipmentId,
|
||||
@@ -140,9 +148,11 @@ class DataService {
|
||||
}
|
||||
|
||||
/// Récupère les événements utilisant un type d'événement donné
|
||||
Future<List<Map<String, dynamic>>> getEventsByEventType(String eventTypeId) async {
|
||||
Future<List<Map<String, dynamic>>> getEventsByEventType(
|
||||
String eventTypeId) async {
|
||||
try {
|
||||
final result = await _apiService.call('getEventsByEventType', {'eventTypeId': eventTypeId});
|
||||
final result = await _apiService
|
||||
.call('getEventsByEventType', {'eventTypeId': eventTypeId});
|
||||
final events = result['events'] as List<dynamic>?;
|
||||
if (events == null) return [];
|
||||
return events.map((e) => e as Map<String, dynamic>).toList();
|
||||
@@ -271,7 +281,8 @@ class DataService {
|
||||
final events = result['events'] as List<dynamic>? ?? [];
|
||||
final users = result['users'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
print('[DataService] Events loaded for $year-$month: ${events.length} events');
|
||||
print(
|
||||
'[DataService] Events loaded for $year-$month: ${events.length} events');
|
||||
|
||||
return {
|
||||
'events': events.map((e) => e as Map<String, dynamic>).toList(),
|
||||
@@ -279,7 +290,8 @@ class DataService {
|
||||
};
|
||||
} catch (e) {
|
||||
print('[DataService] Error getting events by month: $e');
|
||||
throw Exception('Erreur lors de la récupération des événements du mois: $e');
|
||||
throw Exception(
|
||||
'Erreur lors de la récupération des événements du mois: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +311,8 @@ class DataService {
|
||||
throw Exception('Event not found');
|
||||
}
|
||||
|
||||
print('[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers');
|
||||
print(
|
||||
'[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers');
|
||||
|
||||
return {
|
||||
'event': event,
|
||||
@@ -308,7 +321,8 @@ class DataService {
|
||||
};
|
||||
} catch (e) {
|
||||
print('[DataService] Error getting event with details: $e');
|
||||
throw Exception('Erreur lors de la récupération de l\'événement avec détails: $e');
|
||||
throw Exception(
|
||||
'Erreur lors de la récupération de l\'événement avec détails: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,11 +346,13 @@ class DataService {
|
||||
}
|
||||
|
||||
/// Récupère plusieurs équipements par leurs IDs
|
||||
Future<List<Map<String, dynamic>>> getEquipmentsByIds(List<String> equipmentIds) async {
|
||||
Future<List<Map<String, dynamic>>> getEquipmentsByIds(
|
||||
List<String> equipmentIds) async {
|
||||
try {
|
||||
if (equipmentIds.isEmpty) return [];
|
||||
|
||||
print('[DataService] Getting equipments by IDs: ${equipmentIds.length} items');
|
||||
print(
|
||||
'[DataService] Getting equipments by IDs: ${equipmentIds.length} items');
|
||||
final result = await _apiService.call('getEquipmentsByIds', {
|
||||
'equipmentIds': equipmentIds,
|
||||
});
|
||||
@@ -366,11 +382,13 @@ class DataService {
|
||||
}
|
||||
|
||||
/// Récupère plusieurs containers par leurs IDs
|
||||
Future<List<Map<String, dynamic>>> getContainersByIds(List<String> containerIds) async {
|
||||
Future<List<Map<String, dynamic>>> getContainersByIds(
|
||||
List<String> containerIds) async {
|
||||
try {
|
||||
if (containerIds.isEmpty) return [];
|
||||
|
||||
print('[DataService] Getting containers by IDs: ${containerIds.length} items');
|
||||
print(
|
||||
'[DataService] Getting containers by IDs: ${containerIds.length} items');
|
||||
final result = await _apiService.call('getContainersByIds', {
|
||||
'containerIds': containerIds,
|
||||
});
|
||||
@@ -415,22 +433,25 @@ class DataService {
|
||||
params['searchQuery'] = searchQuery;
|
||||
}
|
||||
|
||||
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated(
|
||||
final result =
|
||||
await (_apiService as FirebaseFunctionsApiService).callPaginated(
|
||||
'getEquipmentsPaginated',
|
||||
params,
|
||||
);
|
||||
|
||||
return {
|
||||
'equipments': (result['equipments'] as List<dynamic>?)
|
||||
?.map((e) => e as Map<String, dynamic>)
|
||||
.toList() ?? [],
|
||||
?.map((e) => e as Map<String, dynamic>)
|
||||
.toList() ??
|
||||
[],
|
||||
'hasMore': result['hasMore'] as bool? ?? false,
|
||||
'lastVisible': result['lastVisible'] as String?,
|
||||
'total': result['total'] as int? ?? 0,
|
||||
};
|
||||
} catch (e) {
|
||||
DebugLog.error('[DataService] Error in getEquipmentsPaginated', e);
|
||||
throw Exception('Erreur lors de la récupération paginée des équipements: $e');
|
||||
throw Exception(
|
||||
'Erreur lors de la récupération paginée des équipements: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,22 +481,25 @@ class DataService {
|
||||
params['searchQuery'] = searchQuery;
|
||||
}
|
||||
|
||||
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated(
|
||||
final result =
|
||||
await (_apiService as FirebaseFunctionsApiService).callPaginated(
|
||||
'getContainersPaginated',
|
||||
params,
|
||||
);
|
||||
|
||||
return {
|
||||
'containers': (result['containers'] as List<dynamic>?)
|
||||
?.map((e) => e as Map<String, dynamic>)
|
||||
.toList() ?? [],
|
||||
?.map((e) => e as Map<String, dynamic>)
|
||||
.toList() ??
|
||||
[],
|
||||
'hasMore': result['hasMore'] as bool? ?? false,
|
||||
'lastVisible': result['lastVisible'] as String?,
|
||||
'total': result['total'] as int? ?? 0,
|
||||
};
|
||||
} catch (e) {
|
||||
DebugLog.error('[DataService] Error in getContainersPaginated', e);
|
||||
throw Exception('Erreur lors de la récupération paginée des containers: $e');
|
||||
throw Exception(
|
||||
'Erreur lors de la récupération paginée des containers: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,6 +523,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
|
||||
// ============================================================================
|
||||
@@ -512,7 +686,8 @@ class DataService {
|
||||
return result['user'] as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
print('[DataService] Error getting current user: $e');
|
||||
throw Exception('Erreur lors de la récupération de l\'utilisateur actuel: $e');
|
||||
throw Exception(
|
||||
'Erreur lors de la récupération de l\'utilisateur actuel: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,7 +768,8 @@ class DataService {
|
||||
});
|
||||
return result;
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la récupération des équipements en conflit: $e');
|
||||
throw Exception(
|
||||
'Erreur lors de la récupération des équipements en conflit: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,7 +778,8 @@ class DataService {
|
||||
// ============================================================================
|
||||
|
||||
/// Récupère toutes les maintenances
|
||||
Future<List<Map<String, dynamic>>> getMaintenances({String? equipmentId}) async {
|
||||
Future<List<Map<String, dynamic>>> getMaintenances(
|
||||
{String? equipmentId}) async {
|
||||
try {
|
||||
final data = <String, dynamic>{};
|
||||
if (equipmentId != null) data['equipmentId'] = equipmentId;
|
||||
@@ -619,14 +796,16 @@ class DataService {
|
||||
/// Supprime une maintenance
|
||||
Future<void> deleteMaintenance(String maintenanceId) async {
|
||||
try {
|
||||
await _apiService.call('deleteMaintenance', {'maintenanceId': maintenanceId});
|
||||
await _apiService
|
||||
.call('deleteMaintenance', {'maintenanceId': maintenanceId});
|
||||
} catch (e) {
|
||||
throw Exception('Erreur lors de la suppression de la maintenance: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les containers contenant un équipement
|
||||
Future<List<Map<String, dynamic>>> getContainersByEquipment(String equipmentId) async {
|
||||
Future<List<Map<String, dynamic>>> getContainersByEquipment(
|
||||
String equipmentId) async {
|
||||
try {
|
||||
final result = await _apiService.call('getContainersByEquipment', {
|
||||
'equipmentId': equipmentId,
|
||||
|
||||
@@ -100,6 +100,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
|
||||
// Nom
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
@@ -256,8 +257,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.scale),
|
||||
),
|
||||
keyboardType:
|
||||
const TextInputType.numberWithOptions(decimal: true),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
if (double.tryParse(value) == null) {
|
||||
@@ -279,8 +279,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
labelText: 'Longueur (cm)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType:
|
||||
TextInputType.numberWithOptions(decimal: true),
|
||||
keyboardType: TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
if (double.tryParse(value) == null) {
|
||||
@@ -299,8 +298,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
labelText: 'Largeur (cm)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType:
|
||||
TextInputType.numberWithOptions(decimal: true),
|
||||
keyboardType: TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
if (double.tryParse(value) == null) {
|
||||
@@ -319,8 +317,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
labelText: 'Hauteur (cm)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType:
|
||||
TextInputType.numberWithOptions(decimal: true),
|
||||
keyboardType: TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (value) {
|
||||
if (value != null && value.isNotEmpty) {
|
||||
if (double.tryParse(value) == null) {
|
||||
@@ -455,11 +452,6 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
Future<void> _selectEquipment() async {
|
||||
final equipmentProvider = context.read<EquipmentProvider>();
|
||||
|
||||
// Toujours charger la liste complète pour éviter d'afficher uniquement
|
||||
// la page paginée active d'un autre écran.
|
||||
await equipmentProvider.loadEquipments();
|
||||
if (!mounted) return;
|
||||
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => _EquipmentSelectorDialog(
|
||||
@@ -468,7 +460,6 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@@ -544,8 +535,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
equipmentId: equipmentId,
|
||||
);
|
||||
} catch (e) {
|
||||
DebugLog.error(
|
||||
'Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
|
||||
DebugLog.error('Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,8 +573,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
});
|
||||
|
||||
// Gérer les équipements ajoutés
|
||||
final addedEquipment =
|
||||
_selectedEquipmentIds.difference(container.equipmentIds.toSet());
|
||||
final addedEquipment = _selectedEquipmentIds.difference(container.equipmentIds.toSet());
|
||||
for (final equipmentId in addedEquipment) {
|
||||
try {
|
||||
await provider.addEquipmentToContainer(
|
||||
@@ -592,14 +581,12 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
equipmentId: equipmentId,
|
||||
);
|
||||
} catch (e) {
|
||||
DebugLog.error(
|
||||
'Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
|
||||
DebugLog.error('Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Gérer les équipements retirés
|
||||
final removedEquipment =
|
||||
container.equipmentIds.toSet().difference(_selectedEquipmentIds);
|
||||
final removedEquipment = container.equipmentIds.toSet().difference(_selectedEquipmentIds);
|
||||
for (final equipmentId in removedEquipment) {
|
||||
try {
|
||||
await provider.removeEquipmentFromContainer(
|
||||
@@ -607,8 +594,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
||||
equipmentId: equipmentId,
|
||||
);
|
||||
} catch (e) {
|
||||
DebugLog.error(
|
||||
'Erreur lors du retrait de l\'équipement $equipmentId', e);
|
||||
DebugLog.error('Erreur lors du retrait de l\'équipement $equipmentId', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,8 +630,7 @@ class _EquipmentSelectorDialog extends StatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
State<_EquipmentSelectorDialog> createState() =>
|
||||
_EquipmentSelectorDialogState();
|
||||
State<_EquipmentSelectorDialog> createState() => _EquipmentSelectorDialogState();
|
||||
}
|
||||
|
||||
class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||
@@ -653,14 +638,12 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||
EquipmentCategory? _filterCategory;
|
||||
String _searchQuery = '';
|
||||
late Set<String> _tempSelectedIds;
|
||||
late final Future<void> _loadingFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Créer une copie temporaire des IDs sélectionnés
|
||||
_tempSelectedIds = Set<String>.from(widget.selectedIds);
|
||||
_loadingFuture = widget.equipmentProvider.loadEquipments();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -746,8 +729,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||
},
|
||||
selectedColor: AppColors.rouge,
|
||||
labelStyle: TextStyle(
|
||||
color:
|
||||
_filterCategory == null ? Colors.white : Colors.black,
|
||||
color: _filterCategory == null ? Colors.white : Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -764,9 +746,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||
},
|
||||
selectedColor: AppColors.rouge,
|
||||
labelStyle: TextStyle(
|
||||
color: _filterCategory == category
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
color: _filterCategory == category ? Colors.white : Colors.black,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -798,8 +778,8 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||
|
||||
// Liste des équipements
|
||||
Expanded(
|
||||
child: FutureBuilder<void>(
|
||||
future: _loadingFuture,
|
||||
child: StreamBuilder<List<EquipmentModel>>(
|
||||
stream: widget.equipmentProvider.equipmentStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
@@ -809,15 +789,11 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||
return Center(child: Text('Erreur: ${snapshot.error}'));
|
||||
}
|
||||
|
||||
var equipment = List<EquipmentModel>.from(
|
||||
widget.equipmentProvider.allEquipment,
|
||||
);
|
||||
var equipment = snapshot.data ?? [];
|
||||
|
||||
// Filtrer par catégorie
|
||||
if (_filterCategory != null) {
|
||||
equipment = equipment
|
||||
.where((e) => e.category == _filterCategory)
|
||||
.toList();
|
||||
equipment = equipment.where((e) => e.category == _filterCategory).toList();
|
||||
}
|
||||
|
||||
// Filtrer par recherche
|
||||
@@ -825,8 +801,8 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||
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);
|
||||
(e.brand?.toLowerCase().contains(query) ?? false) ||
|
||||
(e.model?.toLowerCase().contains(query) ?? false);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@@ -969,4 +945,4 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||
return Icons.category;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,8 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
final success = await _controller.submitForm(context, existingEvent: widget.event);
|
||||
final success =
|
||||
await _controller.submitForm(context, existingEvent: widget.event);
|
||||
if (success && mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
@@ -158,21 +159,25 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
},
|
||||
child: Scaffold(
|
||||
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(
|
||||
child: SingleChildScrollView(
|
||||
child: (isMobile
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 12),
|
||||
child: _buildFormContent(isMobile),
|
||||
)
|
||||
: Card(
|
||||
elevation: 6,
|
||||
margin: const EdgeInsets.all(24),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(18)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32, vertical: 32),
|
||||
child: _buildFormContent(isMobile),
|
||||
),
|
||||
)),
|
||||
@@ -186,15 +191,6 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
Widget _buildFormContent(bool isMobile) {
|
||||
return Consumer<EventFormController>(
|
||||
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(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
@@ -209,18 +205,22 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
selectedEventTypeId: controller.selectedEventTypeId,
|
||||
startDateTime: controller.startDateTime,
|
||||
endDateTime: controller.endDateTime,
|
||||
onEventTypeChanged: (typeId) => controller.onEventTypeChanged(typeId, context),
|
||||
onEventTypeChanged: (typeId) =>
|
||||
controller.onEventTypeChanged(typeId, context),
|
||||
onStartDateTimeChanged: controller.setStartDateTime,
|
||||
onEndDateTimeChanged: controller.setEndDateTime,
|
||||
onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
|
||||
onAnyFieldChanged:
|
||||
() {}, // Géré automatiquement par le contrôleur
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OptionSelectorWidget(
|
||||
eventType: controller.selectedEventTypeId, // Utilise l'ID au lieu du nom
|
||||
eventType: controller
|
||||
.selectedEventTypeId, // Utilise l'ID au lieu du nom
|
||||
selectedOptions: controller.selectedOptions,
|
||||
onChanged: controller.setSelectedOptions,
|
||||
onRemove: (optionId) {
|
||||
final newOptions = List<Map<String, dynamic>>.from(controller.selectedOptions);
|
||||
final newOptions = List<Map<String, dynamic>>.from(
|
||||
controller.selectedOptions);
|
||||
newOptions.removeWhere((o) => o['id'] == optionId);
|
||||
controller.setSelectedOptions(newOptions);
|
||||
},
|
||||
@@ -236,6 +236,7 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
endDate: controller.endDateTime,
|
||||
onChanged: controller.setAssignedEquipment,
|
||||
eventId: widget.event?.id,
|
||||
eventTypeId: controller.selectedEventTypeId,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
EventDetailsSection(
|
||||
@@ -247,7 +248,8 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
contactEmailController: controller.contactEmailController,
|
||||
contactPhoneController: controller.contactPhoneController,
|
||||
isMobile: isMobile,
|
||||
onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
|
||||
onAnyFieldChanged:
|
||||
() {}, // Géré automatiquement par le contrôleur
|
||||
),
|
||||
EventStaffAndDocumentsSection(
|
||||
allUsers: controller.allUsers,
|
||||
@@ -290,9 +292,10 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
||||
}
|
||||
},
|
||||
onSubmit: _submit,
|
||||
onSetConfirmed: !isEditMode ? () {
|
||||
} : null,
|
||||
onDelete: isEditMode ? _deleteEvent : null, // Ajout du callback de suppression
|
||||
onSetConfirmed: !isEditMode ? () {} : null,
|
||||
onDelete: isEditMode
|
||||
? _deleteEvent
|
||||
: null, // Ajout du callback de suppression
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
||||
bool shouldAutoLoadNextPage({
|
||||
required bool hasMoreData,
|
||||
required bool isLoadingMore,
|
||||
required bool hasClients,
|
||||
required double maxScrollExtent,
|
||||
}) {
|
||||
if (!hasMoreData || isLoadingMore) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the list cannot scroll yet, preload the next page to avoid a truncated view.
|
||||
return !hasClients || maxScrollExtent <= 0;
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/services/ai_equipment_assistant_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 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;
|
||||
|
||||
@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));
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
_scrollToBottom();
|
||||
|
||||
try {
|
||||
final response = await _assistantService
|
||||
.generateProposal(
|
||||
startDate: widget.startDate,
|
||||
endDate: widget.endDate,
|
||||
eventTypeId: widget.eventTypeId,
|
||||
excludeEventId: widget.excludeEventId,
|
||||
currentAssignedEquipment: widget.currentAssignedEquipment,
|
||||
workingProposalEquipment: _workingEquipment,
|
||||
userMessage: userMessage,
|
||||
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,
|
||||
);
|
||||
}
|
||||
_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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@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('Assistant IA Logisticien'),
|
||||
actions: [
|
||||
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),
|
||||
Text(
|
||||
'Generation en cours... verification du materiel et disponibilites.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
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!),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
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: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: bubbleColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border:
|
||||
message.isUser ? null : Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Text(
|
||||
message.text,
|
||||
style: TextStyle(color: textColor),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProposalSummary(AiEquipmentProposal proposal) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
padding: const EdgeInsets.all(12),
|
||||
constraints: const BoxConstraints(maxHeight: 240),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade50,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.green.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Recapitulatif propose',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
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),
|
||||
if (proposal.items.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
...proposal.items.map((item) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
'- ${item.equipmentId} x${item.quantity} - ${item.rationale}',
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
if (proposal.containerIds.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Boites proposees :',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
...proposal.containerIds.map((id) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text('- $id'),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () => Navigator.of(context).pop(
|
||||
AiProposalResult(
|
||||
equipment: proposal.asEventEquipment,
|
||||
containerIds: proposal.containerIds,
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.add_task),
|
||||
label: const Text('Confirmer et Ajouter'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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/utils/colors.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
|
||||
class EventAssignedEquipmentSection extends StatefulWidget {
|
||||
@@ -17,6 +18,7 @@ class EventAssignedEquipmentSection extends StatefulWidget {
|
||||
final DateTime? endDate;
|
||||
final Function(List<EventEquipment>, List<String>) onChanged;
|
||||
final String? eventId; // Pour exclure l'événement actuel de la vérification
|
||||
final String? eventTypeId;
|
||||
|
||||
const EventAssignedEquipmentSection({
|
||||
super.key,
|
||||
@@ -26,14 +28,18 @@ class EventAssignedEquipmentSection extends StatefulWidget {
|
||||
required this.endDate,
|
||||
required this.onChanged,
|
||||
this.eventId,
|
||||
this.eventTypeId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EventAssignedEquipmentSection> createState() => _EventAssignedEquipmentSectionState();
|
||||
State<EventAssignedEquipmentSection> createState() =>
|
||||
_EventAssignedEquipmentSectionState();
|
||||
}
|
||||
|
||||
class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSection> {
|
||||
bool get _canAddMaterial => widget.startDate != null && widget.endDate != null;
|
||||
class _EventAssignedEquipmentSectionState
|
||||
extends State<EventAssignedEquipmentSection> {
|
||||
bool get _canAddMaterial =>
|
||||
widget.startDate != null && widget.endDate != null;
|
||||
final Map<String, EquipmentModel> _equipmentCache = {};
|
||||
final Map<String, ContainerModel> _containerCache = {};
|
||||
bool _isLoading = true;
|
||||
@@ -61,19 +67,24 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
final equipmentProvider = context.read<EquipmentProvider>();
|
||||
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.
|
||||
final equipmentIds = widget.assignedEquipment.map((eq) => eq.equipmentId).toList();
|
||||
final containers = await containerProvider.getContainersByIds(widget.assignedContainers);
|
||||
final equipmentIds =
|
||||
widget.assignedEquipment.map((eq) => eq.equipmentId).toList();
|
||||
final containers =
|
||||
await containerProvider.getContainersByIds(widget.assignedContainers);
|
||||
|
||||
final childEquipmentIds = <String>[];
|
||||
for (final container in containers) {
|
||||
childEquipmentIds.addAll(container.equipmentIds);
|
||||
}
|
||||
|
||||
final allEquipmentIds = <String>{...equipmentIds, ...childEquipmentIds}.toList();
|
||||
final equipment = await equipmentProvider.getEquipmentsByIds(allEquipmentIds);
|
||||
final allEquipmentIds =
|
||||
<String>{...equipmentIds, ...childEquipmentIds}.toList();
|
||||
final equipment =
|
||||
await equipmentProvider.getEquipmentsByIds(allEquipmentIds);
|
||||
|
||||
_equipmentCache.clear();
|
||||
_containerCache.clear();
|
||||
@@ -110,7 +121,9 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
_containerCache[containerId] = container;
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLog.error('[EventAssignedEquipmentSection] Error loading equipment and containers', e);
|
||||
DebugLog.error(
|
||||
'[EventAssignedEquipmentSection] Error loading equipment and containers',
|
||||
e);
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
@@ -138,7 +151,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
if (newContainers.isNotEmpty) {
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
final containers = await containerProvider.getContainersByIds(newContainers);
|
||||
final containers =
|
||||
await containerProvider.getContainersByIds(newContainers);
|
||||
|
||||
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
|
||||
final existsInNew = newEquipment.any((eq) => eq.equipmentId == childEquipmentId);
|
||||
final existsInNew =
|
||||
newEquipment.any((eq) => eq.equipmentId == childEquipmentId);
|
||||
if (!existsInNew) {
|
||||
newEquipment.add(EventEquipment(
|
||||
equipmentId: childEquipmentId,
|
||||
quantity: 1,
|
||||
));
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Adding child equipment $childEquipmentId from container ${container.id}');
|
||||
DebugLog.info(
|
||||
'[EventAssignedEquipmentSection] Adding child equipment $childEquipmentId from container ${container.id}');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,11 +201,12 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
// Fusionner avec l'existant
|
||||
final updatedEquipment = [...widget.assignedEquipment];
|
||||
final updatedContainers = [...widget.assignedContainers];
|
||||
|
||||
|
||||
// Pour chaque nouvel équipement
|
||||
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) {
|
||||
// L'équipement existe déjà : mettre à jour la quantité
|
||||
updatedEquipment[existingIndex] = EventEquipment(
|
||||
@@ -204,17 +223,64 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
updatedEquipment.add(eq);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for (var containerId in newContainers) {
|
||||
if (!updatedContainers.contains(containerId)) {
|
||||
updatedContainers.add(containerId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Notifier le changement
|
||||
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) {
|
||||
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);
|
||||
}).toList();
|
||||
|
||||
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) {
|
||||
final updated = widget.assignedEquipment
|
||||
.where((eq) => eq.equipmentId != equipmentId)
|
||||
@@ -231,9 +297,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
final container = _containerCache[containerId];
|
||||
|
||||
// Retirer le conteneur de la liste
|
||||
final updatedContainers = widget.assignedContainers
|
||||
.where((id) => id != containerId)
|
||||
.toList();
|
||||
final updatedContainers =
|
||||
widget.assignedContainers.where((id) => id != containerId).toList();
|
||||
|
||||
// 🔧 FIX: Ne supprimer les équipements enfants QUE s'ils ne sont pas dans un autre container
|
||||
final updatedEquipment = <EventEquipment>[];
|
||||
@@ -252,8 +317,10 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
// 1. Ne sont PAS dans le container supprimé OU
|
||||
// 2. Sont dans le container supprimé MAIS aussi dans un autre container
|
||||
for (var eq in widget.assignedEquipment) {
|
||||
final isInRemovedContainer = container.equipmentIds.contains(eq.equipmentId);
|
||||
final isInOtherContainer = equipmentIdsInOtherContainers.contains(eq.equipmentId);
|
||||
final isInRemovedContainer =
|
||||
container.equipmentIds.contains(eq.equipmentId);
|
||||
final isInOtherContainer =
|
||||
equipmentIdsInOtherContainers.contains(eq.equipmentId);
|
||||
|
||||
if (!isInRemovedContainer || isInOtherContainer) {
|
||||
updatedEquipment.add(eq);
|
||||
@@ -271,7 +338,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
_containerCache.remove(containerId);
|
||||
// Nettoyer le cache uniquement pour les équipements effectivement supprimés
|
||||
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) {
|
||||
if (!remainingEquipmentIds.contains(equipmentId)) {
|
||||
_equipmentCache.remove(equipmentId);
|
||||
@@ -301,7 +369,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final totalItems = widget.assignedEquipment.length + widget.assignedContainers.length;
|
||||
final totalItems =
|
||||
widget.assignedEquipment.length + widget.assignedContainers.length;
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
@@ -350,15 +419,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(
|
||||
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(
|
||||
'Ajouter',
|
||||
style: TextStyle(color: _canAddMaterial ? Colors.white : Colors.grey),
|
||||
style: TextStyle(
|
||||
color: _canAddMaterial ? Colors.white : Colors.grey),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _canAddMaterial ? AppColors.rouge : Colors.grey.shade300,
|
||||
backgroundColor: _canAddMaterial
|
||||
? AppColors.rouge
|
||||
: Colors.grey.shade300,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -512,7 +591,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -537,7 +617,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
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),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -562,7 +643,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEquipmentItem(EquipmentModel? equipment, EventEquipment eventEq) {
|
||||
Widget _buildEquipmentItem(
|
||||
EquipmentModel? equipment, EventEquipment eventEq) {
|
||||
if (equipment == null) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
@@ -585,17 +667,15 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
}
|
||||
|
||||
final isConsumable = equipment.category == EquipmentCategory.consumable ||
|
||||
equipment.category == EquipmentCategory.cable;
|
||||
equipment.category == EquipmentCategory.cable;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
|
||||
child: equipment.category.getIconForAvatar(
|
||||
size: 24,
|
||||
color: equipment.category.color
|
||||
),
|
||||
child: equipment.category
|
||||
.getIconForAvatar(size: 24, color: equipment.category.color),
|
||||
),
|
||||
title: Text(
|
||||
equipment.id,
|
||||
@@ -634,4 +714,3 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import 'package:em2rp/views/widgets/event/equipment_selection_pagination.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('shouldAutoLoadNextPage', () {
|
||||
test('returns false when there is no more data', () {
|
||||
final result = shouldAutoLoadNextPage(
|
||||
hasMoreData: false,
|
||||
isLoadingMore: false,
|
||||
hasClients: true,
|
||||
maxScrollExtent: 100,
|
||||
);
|
||||
|
||||
expect(result, isFalse);
|
||||
});
|
||||
|
||||
test('returns false while a page is already loading', () {
|
||||
final result = shouldAutoLoadNextPage(
|
||||
hasMoreData: true,
|
||||
isLoadingMore: true,
|
||||
hasClients: true,
|
||||
maxScrollExtent: 0,
|
||||
);
|
||||
|
||||
expect(result, isFalse);
|
||||
});
|
||||
|
||||
test('returns true when list has no scroll client yet', () {
|
||||
final result = shouldAutoLoadNextPage(
|
||||
hasMoreData: true,
|
||||
isLoadingMore: false,
|
||||
hasClients: false,
|
||||
maxScrollExtent: 0,
|
||||
);
|
||||
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('returns true when list is not scrollable yet', () {
|
||||
final result = shouldAutoLoadNextPage(
|
||||
hasMoreData: true,
|
||||
isLoadingMore: false,
|
||||
hasClients: true,
|
||||
maxScrollExtent: 0,
|
||||
);
|
||||
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('returns false when list is scrollable', () {
|
||||
final result = shouldAutoLoadNextPage(
|
||||
hasMoreData: true,
|
||||
isLoadingMore: false,
|
||||
hasClients: true,
|
||||
maxScrollExtent: 250,
|
||||
);
|
||||
|
||||
expect(result, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": "1.1.19",
|
||||
"version": "1.1.18",
|
||||
"updateUrl": "https://app.em2events.fr",
|
||||
"forceUpdate": true,
|
||||
"releaseNotes": "Fix BUG : Problème de cache avec les équipements non affichés dans le dialog de sélection d'équipement. Amélioration de la gestion du cache pour éviter les problèmes d'affichage.",
|
||||
"timestamp": "2026-03-24T11:14:01.828Z"
|
||||
"releaseNotes": "Fix BUG : Ajout equipement à un evenement existant, boutons de modification de statut d'un evenement ne fonctionnaient pas. Refonte legere de la page calendrier.",
|
||||
"timestamp": "2026-03-12T20:11:54.548Z"
|
||||
}
|
||||
Reference in New Issue
Block a user