feat: Intégration d'un assistant IA logisticien basé sur Gemini

- Ajout d'une Cloud Function `aiEquipmentProposal` utilisant le modèle Gemini avec function calling pour suggérer du matériel et des containers.
- Implémentation de plusieurs outils (tools) côté serveur pour permettre à l'IA d'interagir avec Firestore : `search_equipment`, `check_availability_batch`, `get_past_events`, `search_event_reference` et `search_containers`.
- Ajout de la dépendance `@google/generative-ai` dans le backend.
- Création d'un service Flutter `AiEquipmentAssistantService` pour communiquer avec la nouvelle Cloud Function.
- Ajout d'une interface de dialogue `AiEquipmentAssistantDialog` permettant aux utilisateurs de discuter avec l'IA pour affiner les propositions de matériel.
- Intégration de l'assistant IA dans la section de gestion du matériel des événements (`EventAssignedEquipmentSection`).
- Mise à jour de `DataService` avec de nouvelles méthodes de recherche et de vérification de disponibilité optimisées pour l'assistant.
- Activation du mode développement et configuration des identifiants de test dans `env.dart`.
- Optimisation des paramètres de la Cloud Function (timeout de 300s et 1GiB de RAM) pour supporter les traitements IA.
This commit is contained in:
ElPoyo
2026-03-24 12:00:30 +01:00
parent ecf4a5cede
commit 84c882ac0b
12 changed files with 2193 additions and 107 deletions

View File

@@ -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
}

View 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,
);
}
}

View File

@@ -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,

View File

@@ -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
),
],
),

View File

@@ -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);
}
}

View File

@@ -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
);
}
}