feat: (BETA) Amélioration de l'assistant IA logisticien (Gemini) et support des documents
- **Amélioration de l'IA (Cloud Functions)** :
- Mise à jour du modèle vers `gemini-3.1-flash-lite` et augmentation de la limite des résultats de recherche à 50.
- Optimisation de la gestion des outils : augmentation du nombre d'appels simultanés (`MAX_TOOL_CALLS_PER_ITERATION`) à 40.
- Refonte du système de recherche d'équipements avec une stratégie en deux passes (recherche précise puis catégorielle avec normalisation agressive).
- Nouvelles consignes strictes pour la gestion des unités uniques (quantité de 1 par ID) et priorité aux flight cases (containers).
- Ajout d'une gestion de retry avec temporisation pour les erreurs de quota (429) et de surcharge (503).
- Support de l'analyse de documents joints (devis, listes) envoyés en `inlineData`.
- **Interface de l'Assistant (`AiEquipmentAssistantDialog`)** :
- Ajout de la possibilité de joindre des documents (PDF, images, texte) via `FilePicker` pour analyse par l'IA.
- Implémentation d'une vue de logs de debug détaillée pour suivre le raisonnement de l'IA et les appels d'outils.
- Amélioration visuelle de la discussion : bulles de message stylisées et structuration automatique des réponses (sections "Matériel ajouté" vs "Matériel non trouvé").
- Nouvelles options de confirmation : "Tout ajouter" ou "Ajouter sans alternatives".
- **Modèles et Services** :
- Mise à jour de `EventEquipment` pour inclure un champ `rationale` (justification du choix de l'équipement).
- Correction dans `EventAssignedEquipmentSection` pour ajouter automatiquement les équipements enfants lors de l'ajout d'un container proposé par l'IA.
- Ajout de la gestion des logs et des documents dans `AiEquipmentAssistantService`.
- **UI Divers** :
- Mise à jour de `EquipmentFormPage` pour clarifier le comportement de l'identifiant (auto-génération recommandée).
This commit is contained in:
@@ -174,6 +174,7 @@ ReturnStatus returnStatusFromString(String? status) {
|
||||
class EventEquipment {
|
||||
final String equipmentId; // ID de l'équipement
|
||||
final int quantity; // Quantité initiale assignée
|
||||
final String? rationale; // Explication/Justification (ex: IA alternative)
|
||||
final bool isPrepared; // Validé en préparation
|
||||
final bool isLoaded; // Validé au chargement
|
||||
final bool isUnloaded; // Validé au déchargement
|
||||
@@ -194,6 +195,7 @@ class EventEquipment {
|
||||
EventEquipment({
|
||||
required this.equipmentId,
|
||||
this.quantity = 1,
|
||||
this.rationale,
|
||||
this.isPrepared = false,
|
||||
this.isLoaded = false,
|
||||
this.isUnloaded = false,
|
||||
@@ -212,6 +214,7 @@ class EventEquipment {
|
||||
return EventEquipment(
|
||||
equipmentId: map['equipmentId'] ?? '',
|
||||
quantity: map['quantity'] ?? 1,
|
||||
rationale: map['rationale'],
|
||||
isPrepared: map['isPrepared'] ?? false,
|
||||
isLoaded: map['isLoaded'] ?? false,
|
||||
isUnloaded: map['isUnloaded'] ?? false,
|
||||
@@ -231,6 +234,7 @@ class EventEquipment {
|
||||
return {
|
||||
'equipmentId': equipmentId,
|
||||
'quantity': quantity,
|
||||
'rationale': rationale,
|
||||
'isPrepared': isPrepared,
|
||||
'isLoaded': isLoaded,
|
||||
'isUnloaded': isUnloaded,
|
||||
@@ -249,6 +253,7 @@ class EventEquipment {
|
||||
EventEquipment copyWith({
|
||||
String? equipmentId,
|
||||
int? quantity,
|
||||
String? rationale,
|
||||
bool? isPrepared,
|
||||
bool? isLoaded,
|
||||
bool? isUnloaded,
|
||||
@@ -265,6 +270,7 @@ class EventEquipment {
|
||||
return EventEquipment(
|
||||
equipmentId: equipmentId ?? this.equipmentId,
|
||||
quantity: quantity ?? this.quantity,
|
||||
rationale: rationale ?? this.rationale,
|
||||
isPrepared: isPrepared ?? this.isPrepared,
|
||||
isLoaded: isLoaded ?? this.isLoaded,
|
||||
isUnloaded: isUnloaded ?? this.isUnloaded,
|
||||
|
||||
@@ -12,6 +12,19 @@ class AiAssistantChatTurn {
|
||||
const AiAssistantChatTurn({required this.isUser, required this.text});
|
||||
}
|
||||
|
||||
/// Document à attacher pour demander à l'IA d'analyser un devis, etc.
|
||||
class AiEquipmentDocument {
|
||||
final String base64Data;
|
||||
final String mimeType;
|
||||
final String? fileName;
|
||||
|
||||
const AiEquipmentDocument({
|
||||
required this.base64Data,
|
||||
required this.mimeType,
|
||||
this.fileName,
|
||||
});
|
||||
}
|
||||
|
||||
/// Un item proposé par l'IA dans la liste de matériel.
|
||||
class AiEquipmentProposalItem {
|
||||
final String equipmentId;
|
||||
@@ -48,10 +61,12 @@ class AiEquipmentProposal {
|
||||
class AiEquipmentAssistantResponse {
|
||||
final String assistantMessage;
|
||||
final AiEquipmentProposal? proposal;
|
||||
final List<String> debugLogs;
|
||||
|
||||
const AiEquipmentAssistantResponse({
|
||||
required this.assistantMessage,
|
||||
this.proposal,
|
||||
this.debugLogs = const [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,6 +89,7 @@ class AiEquipmentAssistantService {
|
||||
String? excludeEventId,
|
||||
List<EventEquipment> currentAssignedEquipment = const [],
|
||||
List<EventEquipment> workingProposalEquipment = const [],
|
||||
AiEquipmentDocument? document,
|
||||
}) async {
|
||||
final payload = <String, dynamic>{
|
||||
'startDate': startDate.toIso8601String(),
|
||||
@@ -94,12 +110,23 @@ class AiEquipmentAssistantService {
|
||||
if (eventTypeId != null) payload['eventTypeId'] = eventTypeId;
|
||||
if (excludeEventId != null) payload['excludeEventId'] = excludeEventId;
|
||||
|
||||
if (document != null) {
|
||||
payload['document'] = {
|
||||
'mimeType': document.mimeType,
|
||||
'data': document.base64Data,
|
||||
if (document.fileName != null) 'fileName': document.fileName,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
DebugLog.info('[AiEquipmentAssistantService] Calling aiEquipmentProposal Cloud Function');
|
||||
|
||||
final result = await _apiService.call('aiEquipmentProposal', payload);
|
||||
final assistantMessage = result['assistantMessage']?.toString().trim() ?? '';
|
||||
final proposal = _parseProposal(result['proposal']);
|
||||
|
||||
final rawLogs = result['debugLogs'];
|
||||
final debugLogs = (rawLogs is List) ? rawLogs.map((e) => e.toString()).toList() : <String>[];
|
||||
|
||||
DebugLog.info(
|
||||
'[AiEquipmentAssistantService] Response received, items: ${proposal?.items.length ?? 0}',
|
||||
@@ -110,6 +137,7 @@ class AiEquipmentAssistantService {
|
||||
? assistantMessage
|
||||
: 'Je n\'ai pas pu générer de réponse.',
|
||||
proposal: proposal,
|
||||
debugLogs: debugLogs,
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
DebugLog.error('[AiEquipmentAssistantService] API error', e);
|
||||
@@ -148,7 +176,11 @@ class AiEquipmentAssistantService {
|
||||
quantity: quantity,
|
||||
rationale: rationale,
|
||||
));
|
||||
eventEquipmentList.add(EventEquipment(equipmentId: equipmentId, quantity: quantity));
|
||||
eventEquipmentList.add(EventEquipment(
|
||||
equipmentId: equipmentId,
|
||||
quantity: quantity,
|
||||
rationale: rationale,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -163,11 +163,11 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
||||
TextFormField(
|
||||
controller: _identifierController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Identifiant *',
|
||||
labelText: 'Identifiant (Laissez vide pour auto-génération) *',
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.tag),
|
||||
hintText: isEditing ? null : 'Laissez vide pour générer automatiquement',
|
||||
helperText: isEditing ? 'Non modifiable' : 'Format auto: {Marque4Chars}_{Modèle}',
|
||||
hintText: isEditing ? null : 'Auto-attribué par défaut',
|
||||
helperText: isEditing ? 'Non modifiable' : 'Génération auto recommandée basée sur Marque/Modèle',
|
||||
),
|
||||
enabled: !isEditing,
|
||||
validator: (value) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/services/ai_equipment_assistant_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
/// Résultat retourné par le dialog après confirmation de la proposition IA.
|
||||
class AiProposalResult {
|
||||
@@ -47,6 +50,8 @@ class _AiEquipmentAssistantDialogState
|
||||
String? _errorMessage;
|
||||
AiEquipmentProposal? _latestProposal;
|
||||
late List<EventEquipment> _workingEquipment;
|
||||
AiEquipmentDocument? _selectedDocument;
|
||||
List<String> _sessionLogs = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -90,12 +95,17 @@ class _AiEquipmentAssistantDialogState
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
_messages.add(_AssistantChatMessage.user(userMessage));
|
||||
if (_selectedDocument != null) {
|
||||
_messages.add(_AssistantChatMessage.user('[Document joint : ${_selectedDocument!.fileName ?? "Document"}]'));
|
||||
}
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
_scrollToBottom();
|
||||
|
||||
try {
|
||||
final documentToSend = _selectedDocument;
|
||||
_selectedDocument = null; // Clear after sending
|
||||
final response = await _assistantService
|
||||
.generateProposal(
|
||||
startDate: widget.startDate,
|
||||
@@ -105,6 +115,7 @@ class _AiEquipmentAssistantDialogState
|
||||
currentAssignedEquipment: widget.currentAssignedEquipment,
|
||||
workingProposalEquipment: _workingEquipment,
|
||||
userMessage: userMessage,
|
||||
document: documentToSend,
|
||||
history: _messages
|
||||
.map((message) => AiAssistantChatTurn(
|
||||
isUser: message.isUser, text: message.text))
|
||||
@@ -124,6 +135,7 @@ class _AiEquipmentAssistantDialogState
|
||||
response.proposal!.asEventEquipment,
|
||||
);
|
||||
}
|
||||
_sessionLogs.addAll(response.debugLogs);
|
||||
_isLoading = false;
|
||||
});
|
||||
_scrollToBottom();
|
||||
@@ -159,6 +171,91 @@ class _AiEquipmentAssistantDialogState
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickDocument() async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf', 'txt', 'jpg', 'jpeg', 'png'],
|
||||
withData: true,
|
||||
);
|
||||
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
final file = result.files.first;
|
||||
if (file.bytes != null) {
|
||||
final base64String = base64Encode(file.bytes!);
|
||||
String mimeType = 'application/octet-stream';
|
||||
if (file.extension == 'pdf') mimeType = 'application/pdf';
|
||||
else if (file.extension == 'txt') mimeType = 'text/plain';
|
||||
else if (file.extension == 'jpg' || file.extension == 'jpeg') mimeType = 'image/jpeg';
|
||||
else if (file.extension == 'png') mimeType = 'image/png';
|
||||
|
||||
setState(() {
|
||||
_selectedDocument = AiEquipmentDocument(
|
||||
base64Data: base64String,
|
||||
mimeType: mimeType,
|
||||
fileName: file.name,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = 'Erreur lors de la selection du document : $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showLogsDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Logs de l\'IA'),
|
||||
content: SizedBox(
|
||||
width: 800,
|
||||
height: 600,
|
||||
child: ListView.builder(
|
||||
itemCount: _sessionLogs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final log = _sessionLogs[index];
|
||||
final isError = log.startsWith('[ERROR]');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text(
|
||||
log,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: isError ? Colors.red : Colors.black87,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final fullLogs = _sessionLogs.join('\n');
|
||||
Clipboard.setData(ClipboardData(text: fullLogs));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Logs copiés dans le presse-papiers')),
|
||||
);
|
||||
},
|
||||
child: const Text('Copier tout'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
@@ -172,6 +269,12 @@ class _AiEquipmentAssistantDialogState
|
||||
automaticallyImplyLeading: false,
|
||||
title: const Text('Assistant IA Logisticien'),
|
||||
actions: [
|
||||
if (_sessionLogs.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bug_report),
|
||||
tooltip: 'Voir les logs',
|
||||
onPressed: _showLogsDialog,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed:
|
||||
@@ -208,8 +311,12 @@ class _AiEquipmentAssistantDialogState
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Text(
|
||||
'Generation en cours... verification du materiel et disponibilites.'),
|
||||
Expanded(
|
||||
child: const Text(
|
||||
'Generation en cours... verification du materiel et disponibilites. (Cela peut prendre jusqu\'a une minute en cas de forte affluence)',
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -230,10 +337,42 @@ class _AiEquipmentAssistantDialogState
|
||||
),
|
||||
if (_latestProposal != null)
|
||||
_buildProposalSummary(_latestProposal!),
|
||||
if (_selectedDocument != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, top: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.attach_file, color: Colors.blue, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_selectedDocument!.fileName ?? 'Document joint',
|
||||
style: const TextStyle(color: Colors.blue, fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_selectedDocument = null;
|
||||
});
|
||||
},
|
||||
tooltip: 'Retirer le document',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.attach_file),
|
||||
onPressed: _isLoading ? null : _pickDocument,
|
||||
tooltip: 'Joindre un devis ou document',
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
@@ -274,17 +413,140 @@ class _AiEquipmentAssistantDialogState
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: bubbleColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(message.isUser ? 16 : 4),
|
||||
bottomRight: Radius.circular(message.isUser ? 4 : 16),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
border:
|
||||
message.isUser ? null : Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Text(
|
||||
message.text,
|
||||
style: TextStyle(color: textColor),
|
||||
message.isUser ? null : Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: message.isUser
|
||||
? Text(message.text, style: TextStyle(color: textColor))
|
||||
: _buildAssistantMessageContent(message.text),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAssistantMessageContent(String text) {
|
||||
// Si le message semble structuré par l'IA avec nos nouvelles règles
|
||||
if (text.contains('Matériel ajouté :') || text.contains('Matériel non trouvé')) {
|
||||
final sections = text.split('\n\n');
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: sections.map((section) {
|
||||
final isAdded = section.contains('Matériel ajouté :');
|
||||
final isMissing = section.contains('Matériel non trouvé');
|
||||
|
||||
if (isAdded) {
|
||||
return _buildStatusSection(
|
||||
title: section.split('\n').first,
|
||||
content: section.split('\n').skip(1).join('\n'),
|
||||
icon: Icons.check_circle_outline,
|
||||
color: Colors.green.shade700,
|
||||
bgColor: Colors.green.shade50,
|
||||
);
|
||||
} else if (isMissing) {
|
||||
return _buildStatusSection(
|
||||
title: section.split('\n').first,
|
||||
content: section.split('\n').skip(1).join('\n'),
|
||||
icon: Icons.warning_amber_rounded,
|
||||
color: Colors.orange.shade800,
|
||||
bgColor: Colors.orange.shade50,
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(section),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
return Text(text);
|
||||
}
|
||||
|
||||
Widget _buildStatusSection({
|
||||
required String title,
|
||||
required String content,
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
required Color bgColor,
|
||||
}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title.replaceAll(':', '').trim(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (content.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
content.trim(),
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey.shade800),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmProposal({bool excludeAlternatives = false}) {
|
||||
if (_latestProposal == null) return;
|
||||
|
||||
List<EventEquipment> equipment = List.from(_latestProposal!.asEventEquipment);
|
||||
List<String> containerIds = List.from(_latestProposal!.containerIds);
|
||||
|
||||
if (excludeAlternatives) {
|
||||
// On utilise la liste des items d'origine pour savoir lesquels exclure
|
||||
// car ils contiennent le champ rationale (avant conversion en EventEquipment)
|
||||
final idsToExclude = _latestProposal!.items
|
||||
.where((item) {
|
||||
final rationale = item.rationale.toLowerCase();
|
||||
return rationale.contains('alternative') ||
|
||||
rationale.contains('remplacement') ||
|
||||
rationale.contains('indisponible');
|
||||
})
|
||||
.map((item) => item.equipmentId)
|
||||
.toSet();
|
||||
|
||||
equipment = equipment.where((eq) => !idsToExclude.contains(eq.equipmentId)).toList();
|
||||
}
|
||||
|
||||
Navigator.of(context).pop(
|
||||
AiProposalResult(
|
||||
equipment: equipment,
|
||||
containerIds: containerIds,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -293,21 +555,38 @@ class _AiEquipmentAssistantDialogState
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
padding: const EdgeInsets.all(12),
|
||||
constraints: const BoxConstraints(maxHeight: 240),
|
||||
padding: const EdgeInsets.all(16),
|
||||
constraints: const BoxConstraints(maxHeight: 280),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.shade50,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.green.shade200),
|
||||
color: Colors.indigo.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.indigo.shade200),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Recapitulatif propose',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.assignment_turned_in, color: Colors.indigo),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Récapitulatif de la proposition IA',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: Colors.indigo,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 12),
|
||||
Flexible(
|
||||
child: Scrollbar(
|
||||
controller: _proposalScrollController,
|
||||
@@ -319,28 +598,58 @@ class _AiEquipmentAssistantDialogState
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(proposal.summary),
|
||||
Text(
|
||||
proposal.summary,
|
||||
style: const TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
if (proposal.items.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Matériel individuel :',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
...proposal.items.map((item) {
|
||||
final isAlt = item.rationale.toLowerCase().contains('alternative') || item.rationale.toLowerCase().contains('remplacement');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
'- ${item.equipmentId} x${item.quantity} - ${item.rationale}',
|
||||
padding: const EdgeInsets.only(bottom: 6, left: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
isAlt ? Icons.swap_horiz : Icons.add_circle_outline,
|
||||
size: 14,
|
||||
color: isAlt ? Colors.orange : Colors.indigo,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${item.equipmentId} x${item.quantity}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500)
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
if (proposal.containerIds.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Boites proposees :',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
'Fly-cases & Boîtes :',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
...proposal.containerIds.map((id) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text('- $id'),
|
||||
padding: const EdgeInsets.only(bottom: 6, left: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.inventory_2_outlined, size: 14, color: Colors.indigo),
|
||||
const SizedBox(width: 8),
|
||||
Text(id, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
@@ -349,18 +658,30 @@ class _AiEquipmentAssistantDialogState
|
||||
),
|
||||
),
|
||||
),
|
||||
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'),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
onPressed: _isLoading ? null : () => _confirmProposal(),
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('Tout ajouter'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.indigo,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isLoading ? null : () => _confirmProposal(excludeAlternatives: true),
|
||||
icon: const Icon(Icons.filter_list_off),
|
||||
label: const Text('Ajouter sans alternatives'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.indigo,
|
||||
side: const BorderSide(color: Colors.indigo),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -257,7 +257,7 @@ class _EventAssignedEquipmentSectionState
|
||||
_applyAiProposal(result);
|
||||
}
|
||||
|
||||
void _applyAiProposal(AiProposalResult result) {
|
||||
void _applyAiProposal(AiProposalResult result) async {
|
||||
final existingById = {
|
||||
for (final equipment in widget.assignedEquipment)
|
||||
equipment.equipmentId: equipment,
|
||||
@@ -268,9 +268,30 @@ class _EventAssignedEquipmentSectionState
|
||||
if (existing == null) {
|
||||
return proposed;
|
||||
}
|
||||
return existing.copyWith(quantity: proposed.quantity);
|
||||
return existing.copyWith(quantity: proposed.quantity, rationale: proposed.rationale);
|
||||
}).toList();
|
||||
|
||||
// 🔧 FIX: Pour chaque container ajouté par l'IA, ajouter aussi ses équipements enfants
|
||||
if (result.containerIds.isNotEmpty) {
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
final containers = await containerProvider.getContainersByIds(result.containerIds);
|
||||
|
||||
for (var container in containers) {
|
||||
for (var childEquipmentId in container.equipmentIds) {
|
||||
// Vérifier si l'équipement enfant n'est pas déjà dans la liste (ou déjà ajouté par la proposition)
|
||||
final exists = updatedEquipment.any((eq) => eq.equipmentId == childEquipmentId);
|
||||
if (!exists) {
|
||||
updatedEquipment.add(EventEquipment(
|
||||
equipmentId: childEquipmentId,
|
||||
quantity: 1,
|
||||
rationale: 'Inclus dans ${container.id}',
|
||||
));
|
||||
DebugLog.info('[EventAssignedEquipmentSection] AI adding child equipment $childEquipmentId from container ${container.id}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final updatedContainers = [...widget.assignedContainers];
|
||||
for (final containerId in result.containerIds) {
|
||||
if (!updatedContainers.contains(containerId)) {
|
||||
|
||||
Reference in New Issue
Block a user