### Key Changes:
**AI Equipment Proposal (`functions/aiEquipmentProposal.js`):**
- Updated Gemini model to `gemini-3.1-flash-lite-preview` and updated the API key.
- Increased `MAX_TOOL_ITERATIONS` from 12 to 20.
- Added a new tool `list_equipment_by_category` to allow the AI to browse equipment when specific searches fail.
- Enhanced the system prompt with instructions to handle typos via category exploration and authorized more creative equipment suggestions based on event descriptions.
- Improved the user prompt to include more event context (name, location, notes, and options).
- Set `responseMimeType: 'application/json'` in the generation config for better reliability.
- Improved error logging and user-facing error messages for timeouts.
**UI & Pagination (`lib/views/`):**
- **ContainerFormPage**: Replaced `StreamBuilder` with a paginated list using `DataService` for equipment selection. Added a scroll controller to support infinite scrolling and updated UI colors to use the newer `withValues` API.
- **EquipmentSelectionDialog**:
- Increased pagination limit from 25 to 50 items.
- Implemented `_checkIfMoreItemsNeeded` logic to automatically fetch more pages if filters (like hiding conflicting items) leave the view too empty.
- Added a `NotificationListener` to the `ListView` to trigger pagination on scroll.
- Fixed minor encoding issues in comments.
---
### Proposed Commit Message:
feat: Mise à jour du modèle Gemini et optimisation de la sélection du matériel avec pagination
- Mise à jour du modèle d'IA vers `gemini-3.1-flash-lite-preview` et augmentation de la limite d'itérations des outils à 20.
- Ajout de l'outil `list_equipment_by_category` pour permettre à l'IA d'explorer les alternatives en cas d'échec de recherche textuelle.
- Enrichissement du prompt système et du contexte envoyé à l'IA (nom, lieu, notes et options de l'événement).
- Implémentation de la pagination dans `ContainerFormPage` pour la sélection d'équipements afin d'améliorer les performances.
- Optimisation de `EquipmentSelectionDialog` avec chargement automatique des pages suivantes si les filtres réduisent trop la liste visible.
- Passage à `withValues` pour la gestion des couleurs et amélioration de la gestion des erreurs et du logging.
This commit is contained in:
@@ -9,9 +9,9 @@ const { GoogleGenerativeAI } = require('@google/generative-ai');
|
|||||||
const admin = require('firebase-admin');
|
const admin = require('firebase-admin');
|
||||||
const logger = require('firebase-functions/logger');
|
const logger = require('firebase-functions/logger');
|
||||||
|
|
||||||
const GEMINI_MODEL = 'gemini-2.5-flash';
|
const GEMINI_MODEL = 'gemini-3.1-flash-lite-preview';
|
||||||
const GEMINI_API_KEY = 'AIzaSyBdBdjFLma2pLenmFBlqZHArS4GVF-mclo';
|
const GEMINI_API_KEY = 'AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc';
|
||||||
const MAX_TOOL_ITERATIONS = 12;
|
const MAX_TOOL_ITERATIONS = 20;
|
||||||
const PAST_EVENTS_LIMIT = 5;
|
const PAST_EVENTS_LIMIT = 5;
|
||||||
const SEARCH_RESULTS_LIMIT = 20;
|
const SEARCH_RESULTS_LIMIT = 20;
|
||||||
const EVENT_SEARCH_SCAN_LIMIT = 100;
|
const EVENT_SEARCH_SCAN_LIMIT = 100;
|
||||||
@@ -141,6 +141,25 @@ const AI_TOOLS = [
|
|||||||
required: ['equipmentIds'],
|
required: ['equipmentIds'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'list_equipment_by_category',
|
||||||
|
description: 'Liste le materiel d\'une categorie ou sous-categorie specifique. Utile si search_equipment ne donne rien a cause d\'une faute de frappe ou pour explorer les alternatives.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
category: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Nom de la categorie.',
|
||||||
|
nullable: true,
|
||||||
|
},
|
||||||
|
subCategory: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Nom de la sous-categorie.',
|
||||||
|
nullable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -151,8 +170,10 @@ Tu dois proposer une liste de materiel et de flight cases adaptee a l evenement
|
|||||||
Regles absolues :
|
Regles absolues :
|
||||||
- Tu ne dois JAMAIS ecrire en base de donnees.
|
- Tu ne dois JAMAIS ecrire en base de donnees.
|
||||||
- Utilise search_equipment pour trouver du materiel, check_availability_batch en priorite pour verifier la disponibilite, check_availability pour un cas isole, get_past_events pour t inspirer.
|
- Utilise search_equipment pour trouver du materiel, check_availability_batch en priorite pour verifier la disponibilite, check_availability pour un cas isole, get_past_events pour t inspirer.
|
||||||
|
- Si une recherche precise echoue, utilise list_equipment_by_category pour explorer les categories ou trouver corriger d'eventuelles fautes de frappe de l'utilisateur.
|
||||||
- Si l utilisateur cite un evenement precis (nom/date), appelle d abord search_event_reference pour retrouver cet evenement et reutiliser son materiel ET ses flight cases.
|
- Si l utilisateur cite un evenement precis (nom/date), appelle d abord search_event_reference pour retrouver cet evenement et reutiliser son materiel ET ses flight cases.
|
||||||
- La sous-categorie du materiel est tres importante. Prends-la en compte en priorite.
|
- La sous-categorie du materiel est tres importante. Prends-la en compte en priorite.
|
||||||
|
- Sois libre d'ajouter du materiel pertinent par rapport aux options ou a la description de l'evenement si cela te semble justifie. Explique tes choix dans rationale.
|
||||||
|
|
||||||
Regles sur les flight cases (PRIORITAIRES) :
|
Regles sur les flight cases (PRIORITAIRES) :
|
||||||
- Apres avoir identifie les equipements necessaires, appelle TOUJOURS search_containers avec la liste de leurs IDs.
|
- Apres avoir identifie les equipements necessaires, appelle TOUJOURS search_containers avec la liste de leurs IDs.
|
||||||
@@ -811,7 +832,44 @@ async function toolSearchEventReference(query, dateHint) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exécute un tool Gemini et retourne le résultat.
|
* Recherche des équipements par catégorie et sous-catégorie.
|
||||||
|
*/
|
||||||
|
async function toolListEquipmentByCategory(category, subCategory) {
|
||||||
|
let queryDb = getDb().collection('equipments');
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
queryDb = queryDb.where('category', '==', category);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subCategory) {
|
||||||
|
queryDb = queryDb.where('subCategory', '==', subCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await queryDb.limit(50).get();
|
||||||
|
|
||||||
|
const results = snapshot.docs.map((doc) => {
|
||||||
|
const data = doc.data();
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
name: data.name || doc.id,
|
||||||
|
category: data.category || '',
|
||||||
|
subCategory: data.subCategory || '',
|
||||||
|
brand: data.brand || null,
|
||||||
|
model: data.model || null,
|
||||||
|
status: data.status || '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
category: category || 'all',
|
||||||
|
subCategory: subCategory || 'all',
|
||||||
|
count: results.length,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exécute un tool Gemini et retourne le résultat.
|
||||||
*/
|
*/
|
||||||
async function executeTool(toolCall, excludeEventId, sharedContext) {
|
async function executeTool(toolCall, excludeEventId, sharedContext) {
|
||||||
const { name, args } = toolCall;
|
const { name, args } = toolCall;
|
||||||
@@ -849,6 +907,9 @@ async function executeTool(toolCall, excludeEventId, sharedContext) {
|
|||||||
case 'search_containers':
|
case 'search_containers':
|
||||||
return await toolSearchContainers(args.equipmentIds, args.query || null);
|
return await toolSearchContainers(args.equipmentIds, args.query || null);
|
||||||
|
|
||||||
|
case 'list_equipment_by_category':
|
||||||
|
return await toolListEquipmentByCategory(args.category || null, args.subCategory || null);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return { error: `Tool inconnu: ${name}` };
|
return { error: `Tool inconnu: ${name}` };
|
||||||
}
|
}
|
||||||
@@ -867,9 +928,13 @@ async function executeTool(toolCall, excludeEventId, sharedContext) {
|
|||||||
*/
|
*/
|
||||||
function buildUserPrompt({
|
function buildUserPrompt({
|
||||||
userMessage,
|
userMessage,
|
||||||
|
eventName,
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
location,
|
||||||
|
notes,
|
||||||
|
eventOptions,
|
||||||
currentEquipment,
|
currentEquipment,
|
||||||
workingProposal,
|
workingProposal,
|
||||||
}) {
|
}) {
|
||||||
@@ -882,14 +947,18 @@ function buildUserPrompt({
|
|||||||
|
|
||||||
const isAutoMode = !userMessage || userMessage.trim().length === 0;
|
const isAutoMode = !userMessage || userMessage.trim().length === 0;
|
||||||
const finalMessage = isAutoMode
|
const finalMessage = isAutoMode
|
||||||
? 'Génère automatiquement une proposition de matériel adaptée à cet événement, basée sur les événements similaires passés.'
|
? 'Génère automatiquement une proposition de matériel adaptée à cet événement, en tenant compte des options et des détails fournis, et basée sur les événements similaires passés.'
|
||||||
: userMessage.trim();
|
: userMessage.trim();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'Contexte de l\'événement :',
|
'Contexte de l\'événement :',
|
||||||
|
`- Nom : ${eventName || 'non renseigné'}`,
|
||||||
`- Type d'événement (ID): ${eventTypeId || 'non renseigné'}`,
|
`- Type d'événement (ID): ${eventTypeId || 'non renseigné'}`,
|
||||||
`- Date de début : ${startDate}`,
|
`- Date de début : ${startDate}`,
|
||||||
`- Date de fin : ${endDate}`,
|
`- Date de fin : ${endDate}`,
|
||||||
|
`- Lieu : ${location || 'non renseigné'}`,
|
||||||
|
`- Notes/Description : ${notes || 'aucune'}`,
|
||||||
|
`- Options de l'événement : ${eventOptions || 'aucune'}`,
|
||||||
`- Matériel déjà assigné : ${currentEquipmentStr}`,
|
`- Matériel déjà assigné : ${currentEquipmentStr}`,
|
||||||
`- Proposition courante à modifier : ${workingProposalStr}`,
|
`- Proposition courante à modifier : ${workingProposalStr}`,
|
||||||
'',
|
'',
|
||||||
@@ -981,9 +1050,13 @@ function parseAiResponse(rawText) {
|
|||||||
*/
|
*/
|
||||||
async function handleAiEquipmentProposal(req, res) {
|
async function handleAiEquipmentProposal(req, res) {
|
||||||
const {
|
const {
|
||||||
|
eventName,
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
location,
|
||||||
|
notes,
|
||||||
|
eventOptions,
|
||||||
userMessage,
|
userMessage,
|
||||||
history = [],
|
history = [],
|
||||||
currentEquipment = [],
|
currentEquipment = [],
|
||||||
@@ -1002,7 +1075,7 @@ async function handleAiEquipmentProposal(req, res) {
|
|||||||
systemInstruction: SYSTEM_PROMPT,
|
systemInstruction: SYSTEM_PROMPT,
|
||||||
tools: AI_TOOLS,
|
tools: AI_TOOLS,
|
||||||
toolConfig: { functionCallingConfig: { mode: 'AUTO' } },
|
toolConfig: { functionCallingConfig: { mode: 'AUTO' } },
|
||||||
generationConfig: { temperature: 0.2 },
|
generationConfig: { temperature: 0.2, responseMimeType: 'application/json' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reconstruire l'historique de conversation
|
// Reconstruire l'historique de conversation
|
||||||
@@ -1021,9 +1094,13 @@ async function handleAiEquipmentProposal(req, res) {
|
|||||||
|
|
||||||
const userPrompt = buildUserPrompt({
|
const userPrompt = buildUserPrompt({
|
||||||
userMessage,
|
userMessage,
|
||||||
|
eventName,
|
||||||
eventTypeId,
|
eventTypeId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
location,
|
||||||
|
notes,
|
||||||
|
eventOptions,
|
||||||
currentEquipment,
|
currentEquipment,
|
||||||
workingProposal,
|
workingProposal,
|
||||||
});
|
});
|
||||||
@@ -1151,9 +1228,10 @@ async function handleAiEquipmentProposal(req, res) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[AI] Conversation timeout/error', {
|
logger.error('[AI] Conversation timeout/error', {
|
||||||
message: error?.message || 'unknown',
|
message: error?.message || 'unknown',
|
||||||
|
stack: error?.stack
|
||||||
});
|
});
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
assistantMessage: 'La generation IA a rencontre une erreur technique. Reessaie dans quelques secondes.',
|
assistantMessage: 'Je suis désolé, je n\'ai pas réussi à traiter votre demande en raison d\'un temps trop long ou d\'une erreur technique. Veuillez réessayer avec des requêtes plus spécifiques.',
|
||||||
proposal: null,
|
proposal: null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import 'package:em2rp/providers/container_provider.dart';
|
|||||||
import 'package:em2rp/providers/equipment_provider.dart';
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
import 'package:em2rp/utils/debug_log.dart';
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/utils/id_generator.dart';
|
import 'package:em2rp/utils/id_generator.dart';
|
||||||
|
import 'package:em2rp/services/data_service.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
class ContainerFormPage extends StatefulWidget {
|
class ContainerFormPage extends StatefulWidget {
|
||||||
final ContainerModel? container;
|
final ContainerModel? container;
|
||||||
@@ -635,23 +637,86 @@ class _EquipmentSelectorDialog extends StatefulWidget {
|
|||||||
|
|
||||||
class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final DataService _dataService = DataService(FirebaseFunctionsApiService());
|
||||||
|
|
||||||
EquipmentCategory? _filterCategory;
|
EquipmentCategory? _filterCategory;
|
||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
late Set<String> _tempSelectedIds;
|
late Set<String> _tempSelectedIds;
|
||||||
|
|
||||||
|
final List<EquipmentModel> _paginatedEquipments = [];
|
||||||
|
bool _isLoadingMore = false;
|
||||||
|
bool _hasMoreEquipments = true;
|
||||||
|
String? _lastEquipmentId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Créer une copie temporaire des IDs sélectionnés
|
// Créer une copie temporaire des IDs sélectionnés
|
||||||
_tempSelectedIds = Set<String>.from(widget.selectedIds);
|
_tempSelectedIds = Set<String>.from(widget.selectedIds);
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
_loadNextPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (_isLoadingMore) return;
|
||||||
|
if (_scrollController.hasClients &&
|
||||||
|
_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 300) {
|
||||||
|
if (_hasMoreEquipments) {
|
||||||
|
_loadNextPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadNextPage() async {
|
||||||
|
if (_isLoadingMore || !_hasMoreEquipments) return;
|
||||||
|
setState(() => _isLoadingMore = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _dataService.getEquipmentsPaginated(
|
||||||
|
limit: 50,
|
||||||
|
startAfter: _lastEquipmentId,
|
||||||
|
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
||||||
|
category: _filterCategory != null ? equipmentCategoryToString(_filterCategory!) : null,
|
||||||
|
sortBy: 'id',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
);
|
||||||
|
|
||||||
|
final newEquipments = (result['equipments'] as List<dynamic>)
|
||||||
|
.map((data) => EquipmentModel.fromMap(data as Map<String, dynamic>, data['id'] as String))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_paginatedEquipments.addAll(newEquipments);
|
||||||
|
_hasMoreEquipments = result['hasMore'] as bool? ?? false;
|
||||||
|
_lastEquipmentId = result['lastVisible'] as String?;
|
||||||
|
_isLoadingMore = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoadingMore = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reloadData() async {
|
||||||
|
setState(() {
|
||||||
|
_paginatedEquipments.clear();
|
||||||
|
_lastEquipmentId = null;
|
||||||
|
_hasMoreEquipments = true;
|
||||||
|
});
|
||||||
|
await _loadNextPage();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Dialog(
|
return Dialog(
|
||||||
@@ -701,6 +766,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_searchQuery = '';
|
_searchQuery = '';
|
||||||
});
|
});
|
||||||
|
_reloadData();
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
@@ -709,6 +775,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_searchQuery = value;
|
_searchQuery = value;
|
||||||
});
|
});
|
||||||
|
_reloadData();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -726,6 +793,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_filterCategory = null;
|
_filterCategory = null;
|
||||||
});
|
});
|
||||||
|
_reloadData();
|
||||||
},
|
},
|
||||||
selectedColor: AppColors.rouge,
|
selectedColor: AppColors.rouge,
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
@@ -743,6 +811,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_filterCategory = selected ? category : null;
|
_filterCategory = selected ? category : null;
|
||||||
});
|
});
|
||||||
|
_reloadData();
|
||||||
},
|
},
|
||||||
selectedColor: AppColors.rouge,
|
selectedColor: AppColors.rouge,
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
@@ -760,7 +829,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.rouge.withOpacity(0.1),
|
color: AppColors.rouge.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -778,86 +847,62 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
|
|
||||||
// Liste des équipements
|
// Liste des équipements
|
||||||
Expanded(
|
Expanded(
|
||||||
child: StreamBuilder<List<EquipmentModel>>(
|
child: _paginatedEquipments.isEmpty && !_isLoadingMore
|
||||||
stream: widget.equipmentProvider.equipmentStream,
|
? const Center(child: Text('Aucun équipement trouvé'))
|
||||||
builder: (context, snapshot) {
|
: ListView.builder(
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
controller: _scrollController,
|
||||||
return const Center(child: CircularProgressIndicator());
|
itemCount: _paginatedEquipments.length + (_isLoadingMore ? 1 : 0),
|
||||||
}
|
itemBuilder: (context, index) {
|
||||||
|
if (index == _paginatedEquipments.length) {
|
||||||
if (snapshot.hasError) {
|
return const Center(
|
||||||
return Center(child: Text('Erreur: ${snapshot.error}'));
|
child: Padding(
|
||||||
}
|
padding: EdgeInsets.all(8.0),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
var equipment = snapshot.data ?? [];
|
|
||||||
|
|
||||||
// Filtrer par catégorie
|
|
||||||
if (_filterCategory != null) {
|
|
||||||
equipment = equipment.where((e) => e.category == _filterCategory).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtrer par recherche
|
|
||||||
if (_searchQuery.isNotEmpty) {
|
|
||||||
final query = _searchQuery.toLowerCase();
|
|
||||||
equipment = equipment.where((e) {
|
|
||||||
return e.id.toLowerCase().contains(query) ||
|
|
||||||
(e.brand?.toLowerCase().contains(query) ?? false) ||
|
|
||||||
(e.model?.toLowerCase().contains(query) ?? false);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (equipment.isEmpty) {
|
|
||||||
return const Center(
|
|
||||||
child: Text('Aucun équipement trouvé'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: equipment.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final item = equipment[index];
|
|
||||||
final isSelected = _tempSelectedIds.contains(item.id);
|
|
||||||
|
|
||||||
return CheckboxListTile(
|
|
||||||
value: isSelected,
|
|
||||||
onChanged: (selected) {
|
|
||||||
setState(() {
|
|
||||||
if (selected == true) {
|
|
||||||
_tempSelectedIds.add(item.id);
|
|
||||||
} else {
|
|
||||||
_tempSelectedIds.remove(item.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
title: Text(
|
|
||||||
item.id,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (item.brand != null || item.model != null)
|
|
||||||
Text('${item.brand ?? ''} ${item.model ?? ''}'),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
_getCategoryLabel(item.category),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey.shade600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
),
|
}
|
||||||
secondary: Icon(
|
|
||||||
_getCategoryIcon(item.category),
|
final item = _paginatedEquipments[index];
|
||||||
color: AppColors.rouge,
|
final isSelected = _tempSelectedIds.contains(item.id);
|
||||||
),
|
|
||||||
activeColor: AppColors.rouge,
|
return CheckboxListTile(
|
||||||
);
|
value: isSelected,
|
||||||
},
|
onChanged: (selected) {
|
||||||
);
|
setState(() {
|
||||||
},
|
if (selected == true) {
|
||||||
),
|
_tempSelectedIds.add(item.id);
|
||||||
|
} else {
|
||||||
|
_tempSelectedIds.remove(item.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
title: Text(
|
||||||
|
item.id,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (item.brand != null || item.model != null)
|
||||||
|
Text('${item.brand ?? ''} ${item.model ?? ''}'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_getCategoryLabel(item.category),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
secondary: Icon(
|
||||||
|
_getCategoryIcon(item.category),
|
||||||
|
color: AppColors.rouge,
|
||||||
|
),
|
||||||
|
activeColor: AppColors.rouge,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Boutons d'action
|
// Boutons d'action
|
||||||
@@ -946,3 +991,6 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialise la sélection avec le matériel déjà assigné
|
/// Initialise la slection avec le matriel dj assign
|
||||||
Future<void> _initializeAlreadyAssigned() async {
|
Future<void> _initializeAlreadyAssigned() async {
|
||||||
final Map<String, SelectedItem> initialSelection = {};
|
final Map<String, SelectedItem> initialSelection = {};
|
||||||
|
|
||||||
@@ -250,7 +250,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await _dataService.getEquipmentsPaginated(
|
final result = await _dataService.getEquipmentsPaginated(
|
||||||
limit: 25,
|
limit: 50,
|
||||||
startAfter: _lastEquipmentId,
|
startAfter: _lastEquipmentId,
|
||||||
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
||||||
category: _selectedCategory != null ? equipmentCategoryToString(_selectedCategory!) : null,
|
category: _selectedCategory != null ? equipmentCategoryToString(_selectedCategory!) : null,
|
||||||
@@ -276,8 +276,13 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
|
|
||||||
DebugLog.info('[EquipmentSelectionDialog] Loaded ${newEquipments.length} equipments, total: ${_paginatedEquipments.length}, hasMore: $_hasMoreEquipments');
|
DebugLog.info('[EquipmentSelectionDialog] Loaded ${newEquipments.length} equipments, total: ${_paginatedEquipments.length}, hasMore: $_hasMoreEquipments');
|
||||||
|
|
||||||
// Charger les quantités pour les consommables/câbles de cette page
|
// Charger les quantites pour les consommables/cbles de cette page
|
||||||
await _loadAvailableQuantities(newEquipments);
|
await _loadAvailableQuantities(newEquipments);
|
||||||
|
|
||||||
|
// Vrifier si on doit charger d'autres lments (ex: tout a t filtr)
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_checkIfMoreItemsNeeded();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLog.error('[EquipmentSelectionDialog] Error loading equipment page', e);
|
DebugLog.error('[EquipmentSelectionDialog] Error loading equipment page', e);
|
||||||
@@ -295,7 +300,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final result = await _dataService.getContainersPaginated(
|
final result = await _dataService.getContainersPaginated(
|
||||||
limit: 25,
|
limit: 50,
|
||||||
startAfter: _lastContainerId,
|
startAfter: _lastContainerId,
|
||||||
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
||||||
category: _selectedCategory?.name, // Filtre par catégorie d'équipements
|
category: _selectedCategory?.name, // Filtre par catégorie d'équipements
|
||||||
@@ -358,8 +363,12 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
DebugLog.info('[EquipmentSelectionDialog] Loaded ${newContainers.length} containers, total: ${_paginatedContainers.length}, hasMore: $_hasMoreContainers');
|
DebugLog.info('[EquipmentSelectionDialog] Loaded ${newContainers.length} containers, total: ${_paginatedContainers.length}, hasMore: $_hasMoreContainers');
|
||||||
DebugLog.info('[EquipmentSelectionDialog] Cached ${allEquipmentsToCache.length} equipment(s) from containers, total cache: ${_cachedEquipment.length}');
|
DebugLog.info('[EquipmentSelectionDialog] Cached ${allEquipmentsToCache.length} equipment(s) from containers, total cache: ${_cachedEquipment.length}');
|
||||||
|
|
||||||
// Mettre à jour les statuts de conflit pour les nouveaux containers
|
// Mettre jour les statuts de conflit pour les nouveaux containers
|
||||||
await _updateContainerConflictStatus();
|
await _updateContainerConflictStatus();
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_checkIfMoreItemsNeeded();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLog.error('[EquipmentSelectionDialog] Error loading container page', e);
|
DebugLog.error('[EquipmentSelectionDialog] Error loading container page', e);
|
||||||
@@ -387,6 +396,40 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _checkIfMoreItemsNeeded() {
|
||||||
|
if (!mounted || _isLoadingMore) return;
|
||||||
|
|
||||||
|
int visibleItems = 0;
|
||||||
|
if (_displayType == SelectionType.equipment) {
|
||||||
|
visibleItems = _paginatedEquipments.where((eq) {
|
||||||
|
return _showConflictingItems || !_conflictingEquipmentIds.contains(eq.id);
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
if (visibleItems < 15 && _hasMoreEquipments) {
|
||||||
|
_loadNextEquipmentPage();
|
||||||
|
} else if (_scrollController.hasClients && _scrollController.position.maxScrollExtent <= 0 && _hasMoreEquipments) {
|
||||||
|
_loadNextEquipmentPage();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
visibleItems = _paginatedContainers.where((container) {
|
||||||
|
if (!_showConflictingItems) {
|
||||||
|
if (_conflictingContainerIds.contains(container.id)) return false;
|
||||||
|
final hasConflictingChildren = container.equipmentIds.any(
|
||||||
|
(eqId) => _conflictingEquipmentIds.contains(eqId),
|
||||||
|
);
|
||||||
|
if (hasConflictingChildren) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
if (visibleItems < 15 && _hasMoreContainers) {
|
||||||
|
_loadNextContainerPage();
|
||||||
|
} else if (_scrollController.hasClients && _scrollController.position.maxScrollExtent <= 0 && _hasMoreContainers) {
|
||||||
|
_loadNextContainerPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
@@ -1358,12 +1401,23 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView(
|
return NotificationListener<ScrollNotification>(
|
||||||
controller: _scrollController,
|
onNotification: (ScrollNotification scrollInfo) {
|
||||||
padding: const EdgeInsets.all(16),
|
if (!_isLoadingMore && scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent - 300) {
|
||||||
children: [
|
if (_displayType == SelectionType.equipment && _hasMoreEquipments) {
|
||||||
// Header
|
_loadNextEquipmentPage();
|
||||||
_buildSectionHeader(
|
} else if (_displayType == SelectionType.container && _hasMoreContainers) {
|
||||||
|
_loadNextContainerPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: ListView(
|
||||||
|
controller: _scrollController,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
_buildSectionHeader(
|
||||||
_displayType == SelectionType.equipment ? 'Équipements' : 'Containers',
|
_displayType == SelectionType.equipment ? 'Équipements' : 'Containers',
|
||||||
_displayType == SelectionType.equipment ? Icons.inventory_2 : Icons.inventory,
|
_displayType == SelectionType.equipment ? Icons.inventory_2 : Icons.inventory,
|
||||||
itemWidgets.length,
|
itemWidgets.length,
|
||||||
@@ -1411,7 +1465,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1735,8 +1790,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Widget pour le sélecteur de quantité
|
/// Widget pour le sélecteur de quantité
|
||||||
@@ -2040,7 +2095,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user