### 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:
ElPoyo
2026-03-30 12:32:33 +02:00
parent 84c882ac0b
commit 89ab3673c4
3 changed files with 284 additions and 103 deletions
+85 -7
View File
@@ -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;
+85 -37
View File
@@ -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,44 +847,22 @@ 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 Center(child: Text('Erreur: ${snapshot.error}'));
}
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( return const Center(
child: Text('Aucun équipement trouvé'), child: Padding(
padding: EdgeInsets.all(8.0),
child: CircularProgressIndicator(),
),
); );
} }
return ListView.builder( final item = _paginatedEquipments[index];
itemCount: equipment.length,
itemBuilder: (context, index) {
final item = equipment[index];
final isSelected = _tempSelectedIds.contains(item.id); final isSelected = _tempSelectedIds.contains(item.id);
return CheckboxListTile( return CheckboxListTile(
@@ -855,8 +902,6 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
activeColor: AppColors.rouge, activeColor: AppColors.rouge,
); );
}, },
);
},
), ),
), ),
@@ -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,7 +1401,18 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
}).toList(); }).toList();
} }
return ListView( return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification scrollInfo) {
if (!_isLoadingMore && scrollInfo.metrics.pixels >= scrollInfo.metrics.maxScrollExtent - 300) {
if (_displayType == SelectionType.equipment && _hasMoreEquipments) {
_loadNextEquipmentPage();
} else if (_displayType == SelectionType.container && _hasMoreContainers) {
_loadNextContainerPage();
}
}
return false;
},
child: ListView(
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
@@ -1412,6 +1466,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
), ),
), ),
], ],
),
); );
}, },
); );