Files
EM2_ERP/em2rp/lib/views/equipment_management_page.dart
ElPoyo a7e5f91a21 feat: Scan et traitement intelligent des QR Codes en préparation d'événement
Cette mise à jour majeure introduit une fonctionnalité de scan et de saisie manuelle de codes QR directement depuis la page de préparation d'un événement. Ce système accélère et fiabilise le processus de validation des équipements et des containers pour chaque étape (préparation, chargement, etc.), tout en ajoutant des retours sonores, haptiques et visuels pour une expérience utilisateur améliorée.

**Fonctionnalités et améliorations principales :**

-   **Scan et saisie manuelle en préparation d'événement :**
    -   Ajout d'un champ de "Saisie manuelle" et d'un bouton "Scanner QR Code" sur la page de préparation (`EventPreparationPage`).
    -   Le scanner peut fonctionner en mode "multi-scan", permettant de valider plusieurs éléments à la suite sans fermer la caméra.
    -   Le système gère à la fois les équipements individuels et les containers (qui valident automatiquement tout leur contenu).

-   **Logique de traitement intelligente (`QRCodeProcessingService`) :**
    -   Un nouveau service centralise la logique de traitement des codes.
    -   Pour les équipements quantitatifs, chaque scan incrémente la quantité jusqu'à atteindre la cible requise pour l'étape en cours.
    -   Pour les équipements non quantitatifs, le premier scan valide l'élément.
    -   Les scans multiples d'un élément déjà validé ou dont la quantité est atteinte génèrent une erreur.

-   **Ajout dynamique d'équipements :**
    -   Si un code scanné n'est pas assigné à l'événement, une boîte de dialogue propose de rechercher l'équipement ou le container dans la base de données et de l'ajouter à l'événement en cours.

-   **Feedbacks utilisateur :**
    -   Création d'un `AudioFeedbackService` pour fournir des retours sonores (succès/erreur) et haptiques (vibration) lors de chaque scan.
    -   Des `Snackbars` claires (vertes pour succès, orange pour erreur) informent l'utilisateur du résultat de chaque action.

-   **Optimisation du chargement des données :**
    -   Nouvel endpoint backend `getEventWithDetails` qui charge un événement et toutes ses dépendances (équipements, containers et leurs enfants) en un seul appel, optimisant drastiquement les temps de chargement des pages de préparation et de modification d'événement.
    -   Le frontend (`EventPreparationPage`, `EventAssignedEquipmentSection`) utilise ce nouvel endpoint, éliminant les chargements multiples et fiabilisant l'affichage des données.

**Refactorisation et corrections :**

-   **Structure du code :**
    -   La logique de traitement des codes est extraite dans le `QRCodeProcessingService`.
    -   Création de widgets dédiés (`CodeNotFoundDialog`, `AddEquipmentToEventDialog`) pour gérer les nouveaux flux utilisateurs.
-   **Fiabilisation de l'état :**
    -   Mise à jour optimiste de l'UI lors du changement de statut d'un événement (`EventStatusButton`) pour une meilleure réactivité.
    -   Correction d'un bug dans la suppression d'un container d'un événement, qui pouvait retirer des équipements partagés avec d'autres containers.
    -   Correction d'un bug lors de l'ajout d'un container à un événement, qui n'ajoutait pas automatiquement ses équipements enfants.
-   **Optimisations des performances UI :**
    -   Amélioration de la fluidité du défilement infini sur la page de gestion des équipements grâce à `RepaintBoundary` et à une gestion optimisée du chargement.

**Déploiement et version :**

-   **Scripts de déploiement :** Ajout d'un script PowerShell (`deploy_hosting.ps1`) et amélioration du script Node.js pour automatiser et fiabiliser les déploiements sur Firebase Hosting.
-   **Configuration CORS :** Les en-têtes CORS sont désormais configurés pour `version.json`, assurant le bon fonctionnement du mécanisme de mise à jour de l'application.
-   **Version de l'application :** Incrémentée à `1.0.6`.
2026-01-20 14:33:37 +01:00

1208 lines
42 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/utils/permission_gate.dart';
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/views/equipment_form_page.dart';
import 'package:em2rp/views/equipment_detail_page.dart';
import 'package:em2rp/views/container_detail_page.dart';
import 'package:em2rp/views/widgets/common/qr_code_dialog.dart';
import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/mixins/selection_mode_mixin.dart';
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
import 'package:em2rp/views/widgets/notification_badge.dart';
class EquipmentManagementPage extends StatefulWidget {
const EquipmentManagementPage({super.key});
@override
State<EquipmentManagementPage> createState() =>
_EquipmentManagementPageState();
}
class _EquipmentManagementPageState extends State<EquipmentManagementPage>
with SelectionModeMixin<EquipmentManagementPage> {
final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
EquipmentCategory? _selectedCategory;
List<EquipmentModel>? _cachedEquipment;
bool _isLoadingMore = false; // Flag pour éviter les appels multiples
@override
void initState() {
super.initState();
DebugLog.info('[EquipmentManagementPage] initState called');
// Activer le mode pagination
final provider = context.read<EquipmentProvider>();
provider.enablePagination();
// Ajouter le listener de scroll pour le chargement infini
_scrollController.addListener(_onScroll);
// Charger la première page au démarrage
WidgetsBinding.instance.addPostFrameCallback((_) {
DebugLog.info('[EquipmentManagementPage] Loading first page...');
provider.loadFirstPage();
});
}
void _onScroll() {
// Éviter les appels multiples avec un flag simple (sans setState)
if (_isLoadingMore) return;
final provider = context.read<EquipmentProvider>();
// Charger la page suivante quand on arrive à 300px du bas
if (_scrollController.hasClients &&
_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 300) {
// Vérifier qu'on peut charger plus
if (provider.hasMore && !provider.isLoadingMore) {
// ✅ Pas de setState ici pour éviter les rebuilds pendant le scroll
_isLoadingMore = true;
provider.loadNextPage().then((_) {
_isLoadingMore = false;
}).catchError((error) {
_isLoadingMore = false;
DebugLog.error('[EquipmentManagementPage] Error loading next page', error);
});
}
}
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_searchController.dispose();
// Désactiver le mode pagination en quittant
context.read<EquipmentProvider>().disablePagination();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isMobile = MediaQuery.of(context).size.width < 800;
return PermissionGate(
requiredPermissions: const ['view_equipment'],
fallback: Scaffold(
appBar: const CustomAppBar(title: 'Accès refusé'),
drawer: const MainDrawer(currentPage: '/equipment_management'),
body: const Center(
child: Padding(
padding: EdgeInsets.all(24.0),
child: Text(
'Vous n\'avez pas les permissions nécessaires pour accéder à la gestion du matériel.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16),
),
),
),
),
child: Scaffold(
appBar: isSelectionMode
? AppBar(
backgroundColor: AppColors.rouge,
leading: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: toggleSelectionMode,
),
title: Text(
'$selectedCount sélectionné(s)',
style: const TextStyle(color: Colors.white),
),
actions: [
const NotificationBadge(),
if (hasSelection) ...[
IconButton(
icon: const Icon(Icons.qr_code, color: Colors.white),
tooltip: 'Générer QR Codes',
onPressed: _generateQRCodesForSelected,
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.white),
tooltip: 'Supprimer',
onPressed: _deleteSelectedEquipment,
),
],
],
)
: CustomAppBar(
title: 'Gestion du matériel',
),
drawer: const MainDrawer(currentPage: '/equipment_management'),
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
floatingActionButton: isSelectionMode ? null : _buildFAB(),
),
);
}
Widget _buildFAB() {
return PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: FloatingActionButton.extended(
onPressed: _createNewEquipment,
backgroundColor: AppColors.rouge,
icon: const Icon(Icons.add),
label: const Text('Ajouter un équipement'),
),
);
}
Widget _buildMobileLayout() {
return Column(
children: [
// Barre de recherche et boutons d'action
SearchActionsBar(
controller: _searchController,
hintText: 'Rechercher par nom, modèle ou ID...',
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
onClear: () {
_searchController.clear();
context.read<EquipmentProvider>().setSearchQuery('');
},
actions: [
IconButton.filled(
onPressed: _scanQRCode,
icon: const Icon(Icons.qr_code_scanner),
tooltip: 'Scanner un QR Code',
style: IconButton.styleFrom(
backgroundColor: Colors.grey[700],
foregroundColor: Colors.white,
),
),
IconButton.filled(
onPressed: () {
Navigator.pushNamed(context, '/container_management');
},
icon: const Icon(Icons.inventory_2),
tooltip: 'Gérer les boîtes',
style: IconButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
),
],
),
// Menu horizontal de filtres par catégorie
SizedBox(
height: 60,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ChoiceChip(
label: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.all_inclusive, size: 16, color: Colors.white),
SizedBox(width: 8),
Text('Tout'),
],
),
selected: _selectedCategory == null,
onSelected: (selected) {
if (selected) {
setState(() => _selectedCategory = null);
context
.read<EquipmentProvider>()
.setSelectedCategory(null);
}
},
selectedColor: AppColors.rouge,
labelStyle: TextStyle(
color: _selectedCategory == null
? Colors.white
: AppColors.rouge,
fontWeight: _selectedCategory == null
? FontWeight.bold
: FontWeight.normal,
),
),
),
..._buildCategoryChips(),
],
),
),
const Divider(),
// Liste des équipements
Expanded(child: _buildEquipmentList()),
],
);
}
Widget _buildDesktopLayout() {
return Row(
children: [
// Sidebar gauche avec filtres
Container(
width: 280,
decoration: BoxDecoration(
color: Colors.grey[100],
border: const Border(
right: BorderSide(color: Colors.grey, width: 1),
),
),
child: Column(
children: [
const SizedBox(height: 16),
// En-tête filtres
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
Icon(Icons.filter_list, color: AppColors.rouge, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
'Filtres',
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: AppColors.rouge,
),
),
),
],
),
),
// Filtres par catégorie
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Catégories',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.grey[700],
),
),
),
),
Expanded(
child: ListView(
children: [
ListTile(
leading: Icon(
Icons.all_inclusive,
color: _selectedCategory == null
? AppColors.rouge
: Colors.grey[600],
),
title: Text(
'Tout',
style: TextStyle(
color: _selectedCategory == null
? AppColors.rouge
: Colors.black87,
fontWeight: _selectedCategory == null
? FontWeight.bold
: FontWeight.normal,
),
),
selected: _selectedCategory == null,
selectedTileColor: AppColors.rouge.withOpacity(0.1),
onTap: () {
setState(() => _selectedCategory = null);
context
.read<EquipmentProvider>()
.setSelectedCategory(null);
},
),
..._buildCategoryListTiles(),
],
),
),
],
),
),
// Contenu principal
Expanded(
child: Column(
children: [
SearchActionsBar(
controller: _searchController,
hintText: 'Rechercher par nom, modèle ou ID...',
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
onClear: () {
_searchController.clear();
context.read<EquipmentProvider>().setSearchQuery('');
},
actions: [
IconButton.filled(
onPressed: _scanQRCode,
icon: const Icon(Icons.qr_code_scanner),
tooltip: 'Scanner un QR Code',
style: IconButton.styleFrom(
backgroundColor: Colors.grey[700],
foregroundColor: Colors.white,
),
),
IconButton.filled(
onPressed: () {
Navigator.pushNamed(context, '/container_management');
},
icon: const Icon(Icons.inventory_2),
tooltip: 'Gérer les boîtes',
style: IconButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
),
if (!isSelectionMode)
IconButton.filled(
onPressed: toggleSelectionMode,
icon: const Icon(Icons.checklist),
tooltip: 'Mode sélection',
style: IconButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
),
],
),
Expanded(child: _buildEquipmentList()),
],
),
),
],
);
}
List<Widget> _buildCategoryChips() {
return EquipmentCategory.values.map((category) {
final isSelected = _selectedCategory == category;
final color = isSelected ? Colors.white : AppColors.rouge;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ChoiceChip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
category.getIcon(
size: 16,
color: color,
),
const SizedBox(width: 8),
Text(category.label),
],
),
selected: isSelected,
onSelected: (selected) {
if (selected) {
setState(() => _selectedCategory = category);
context.read<EquipmentProvider>().setSelectedCategory(category);
}
},
selectedColor: AppColors.rouge,
labelStyle: TextStyle(
color: color,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
);
}).toList();
}
List<Widget> _buildCategoryListTiles() {
return EquipmentCategory.values.map((category) {
final isSelected = _selectedCategory == category;
final color = isSelected ? AppColors.rouge : Colors.grey[600]!;
return ListTile(
leading: category.getIcon(
size: 24,
color: color,
),
title: Text(
category.label,
style: TextStyle(
color: isSelected ? AppColors.rouge : Colors.black87,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
selected: isSelected,
selectedTileColor: AppColors.rouge.withOpacity(0.1),
onTap: () {
setState(() => _selectedCategory = category);
context.read<EquipmentProvider>().setSelectedCategory(category);
},
);
}).toList();
}
Widget _buildEquipmentList() {
return Consumer<EquipmentProvider>(
builder: (context, provider, child) {
DebugLog.info('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
// Afficher l'indicateur de chargement initial uniquement
if (provider.isLoading && provider.equipment.isEmpty) {
DebugLog.info('[EquipmentManagementPage] Showing initial loading indicator');
return const Center(child: CircularProgressIndicator());
}
final equipments = provider.equipment;
if (equipments.isEmpty && !provider.isLoading) {
DebugLog.info('[EquipmentManagementPage] No equipment found');
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_outlined,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Aucun équipement trouvé',
style: TextStyle(
fontSize: 18,
color: Colors.grey[600],
),
),
],
),
);
}
DebugLog.info('[EquipmentManagementPage] Building list with ${equipments.length} items');
// Calculer le nombre total d'items (équipements + indicateur de chargement)
final itemCount = equipments.length + (provider.hasMore ? 1 : 0);
return ListView.builder(
controller: _scrollController,
itemCount: itemCount,
// ✅ Ajouter une estimation de la hauteur pour améliorer le scroll
// Note : À ajuster selon la hauteur réelle de vos cartes
// itemExtent: 140, // Décommentez si toutes les cartes ont la même hauteur
// ✅ Augmenter le cache pour un scroll plus fluide
cacheExtent: 500, // Précharger 500px en plus
itemBuilder: (context, index) {
// Dernier élément = indicateur de chargement
if (index == equipments.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
);
}
return _buildEquipmentCard(equipments[index]);
},
);
},
);
}
Widget _buildEquipmentCard(EquipmentModel equipment) {
final isSelected = isItemSelected(equipment.id);
// ✅ RepaintBoundary pour isoler le repaint de chaque carte
return RepaintBoundary(
key: ValueKey(equipment.id),
child: Card(
margin: const EdgeInsets.only(bottom: 12),
color: isSelectionMode && isSelected
? AppColors.rouge.withValues(alpha: 0.1)
: null,
child: ListTile(
leading: isSelectionMode
? Checkbox(
value: isSelected,
onChanged: (value) => toggleItemSelection(equipment.id),
activeColor: AppColors.rouge,
)
: CircleAvatar(
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
child: equipment.category.getIcon(
size: 20,
color: equipment.category.color,
),
),
title: Row(
children: [
Expanded(
child: Text(
equipment.id,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
// Afficher le badge de statut calculé dynamiquement
if (equipment.category != EquipmentCategory.consumable &&
equipment.category != EquipmentCategory.cable)
EquipmentStatusBadge(equipment: equipment),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
.trim()
.isNotEmpty
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()
: 'Marque/Modèle non défini',
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
// Afficher la sous-catégorie si elle existe
if (equipment.subCategory != null && equipment.subCategory!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
'📁 ${equipment.subCategory}',
style: TextStyle(
color: Colors.grey[500],
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
],
// Afficher la quantité disponible pour les consommables/câbles
if (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable) ...[
const SizedBox(height: 4),
_buildQuantityDisplay(equipment),
],
],
),
trailing: isSelectionMode
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton Restock (uniquement pour consommables/câbles avec permission)
if (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.add_shopping_cart,
color: AppColors.rouge),
tooltip: 'Restock',
onPressed: () => _showRestockDialog(equipment),
),
),
// Bouton QR Code
IconButton(
icon: const Icon(Icons.qr_code, color: AppColors.rouge),
tooltip: 'QR Code',
onPressed: () => showDialog(
context: context,
builder: (context) => QRCodeDialog.forEquipment(equipment),
),
),
// Bouton Modifier (permission required)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.edit, color: AppColors.rouge),
tooltip: 'Modifier',
onPressed: () => _editEquipment(equipment),
),
),
// Bouton Supprimer (permission required)
PermissionGate(
requiredPermissions: const ['manage_equipment'],
child: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Supprimer',
onPressed: () => _deleteEquipment(equipment),
),
),
],
),
onTap: isSelectionMode
? () => toggleItemSelection(equipment.id)
: () => _viewEquipmentDetails(equipment),
),
)
);
}
Widget _buildQuantityDisplay(EquipmentModel equipment) {
final availableQty = equipment.availableQuantity ?? 0;
final totalQty = equipment.totalQuantity ?? 0;
final criticalThreshold = equipment.criticalThreshold ?? 0;
final isCritical =
criticalThreshold > 0 && availableQty <= criticalThreshold;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isCritical
? Colors.red.withOpacity(0.15)
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isCritical ? Colors.red : Colors.grey.shade400,
width: isCritical ? 2 : 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isCritical ? Icons.warning : Icons.inventory,
size: 16,
color: isCritical ? Colors.red : Colors.grey[700],
),
const SizedBox(width: 6),
Text(
'Disponible: $availableQty / $totalQty',
style: TextStyle(
fontSize: 13,
fontWeight: isCritical ? FontWeight.bold : FontWeight.normal,
color: isCritical ? Colors.red : Colors.grey[700],
),
),
if (isCritical) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'CRITIQUE',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
],
],
),
);
}
// Actions
void _createNewEquipment() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const EquipmentFormPage(),
),
);
}
void _editEquipment(EquipmentModel equipment) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EquipmentFormPage(equipment: equipment),
),
);
}
void _deleteEquipment(EquipmentModel equipment) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text('Voulez-vous vraiment supprimer "${equipment.name}" ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
try {
await context
.read<EquipmentProvider>()
.deleteEquipment(equipment.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Équipement supprimé avec succès')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
}
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
),
],
),
);
}
void _deleteSelectedEquipment() async {
if (!hasSelection) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text(
'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
try {
final provider = context.read<EquipmentProvider>();
for (final id in selectedIds) {
await provider.deleteEquipment(id);
}
disableSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'$selectedCount équipement(s) supprimé(s) avec succès'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
}
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
),
],
),
);
}
void _generateQRCodesForSelected() async {
if (!hasSelection) return;
// Afficher un indicateur de chargement
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(color: AppColors.rouge),
),
);
try {
// Récupérer les équipements sélectionnés
final provider = context.read<EquipmentProvider>();
final List<EquipmentModel> selectedEquipment = [];
// On doit récupérer les équipements depuis le stream
await for (final equipmentList in provider.equipmentStream.take(1)) {
for (final equipment in equipmentList) {
if (isItemSelected(equipment.id)) {
selectedEquipment.add(equipment);
}
}
break;
}
// Fermer l'indicateur de chargement
if (mounted) {
Navigator.of(context).pop();
}
if (selectedEquipment.isEmpty) return;
if (selectedEquipment.length == 1) {
// Un seul équipement : afficher le dialogue simple
if (mounted) {
showDialog(
context: context,
builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first),
);
}
} else {
// Plusieurs équipements : afficher le sélecteur de format
if (mounted) {
showDialog(
context: context,
builder: (context) => QRCodeFormatSelectorDialog<EquipmentModel>(
itemList: selectedEquipment,
getId: (eq) => eq.id,
getTitle: (eq) => '${eq.brand ?? ''} ${eq.model ?? ''}'.trim(),
dialogTitle: 'Générer ${selectedEquipment.length} QR Code(s)',
),
);
}
}
} catch (e) {
// Fermer l'indicateur si une erreur survient
if (mounted) {
Navigator.of(context).pop();
}
DebugLog.error('[EquipmentManagementPage] Error generating QR codes', e);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la génération : ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
void _showRestockDialog(EquipmentModel equipment) {
final TextEditingController quantityController = TextEditingController();
bool addToTotal = false;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: Row(
children: [
const Icon(Icons.add_shopping_cart, color: AppColors.rouge),
const SizedBox(width: 12),
Expanded(
child: Text('Restock - ${equipment.name}'),
),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Quantités actuelles',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey[700],
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Disponible:'),
Text(
'${equipment.availableQuantity ?? 0}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Total:'),
Text(
'${equipment.totalQuantity ?? 0}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
],
),
),
const SizedBox(height: 20),
TextField(
controller: quantityController,
decoration: const InputDecoration(
labelText: 'Quantité à ajouter/retirer',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.inventory),
hintText: 'Ex: 10 ou -5',
helperText:
'Nombre positif pour ajouter, négatif pour retirer',
),
keyboardType:
const TextInputType.numberWithOptions(signed: true),
autofocus: true,
),
const SizedBox(height: 16),
CheckboxListTile(
title: const Text('Ajouter à la quantité totale'),
subtitle:
const Text('Mettre à jour aussi la quantité totale'),
value: addToTotal,
contentPadding: EdgeInsets.zero,
onChanged: (bool? value) {
setState(() {
addToTotal = value ?? false;
});
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () async {
final quantityText = quantityController.text.trim();
if (quantityText.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez entrer une quantité')),
);
return;
}
final quantity = int.tryParse(quantityText);
if (quantity == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Quantité invalide')),
);
return;
}
Navigator.pop(context);
try {
final currentAvailable = equipment.availableQuantity ?? 0;
final currentTotal = equipment.totalQuantity ?? 0;
final newAvailable = currentAvailable + quantity;
final newTotal =
addToTotal ? currentTotal + quantity : currentTotal;
if (newAvailable < 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'La quantité disponible ne peut pas être négative')),
);
return;
}
if (newTotal < 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'La quantité totale ne peut pas être négative')),
);
return;
}
final updatedData = {
'availableQuantity': newAvailable,
'totalQuantity': newTotal,
'updatedAt': DateTime.now().toIso8601String(),
};
final updatedEquipment = equipment.copyWith(
availableQuantity: newAvailable,
totalQuantity: newTotal,
updatedAt: DateTime.now(),
);
await context.read<EquipmentProvider>().updateEquipment(updatedEquipment);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
quantity > 0
? 'Ajout de $quantity unité(s) effectué'
: 'Retrait de ${quantity.abs()} unité(s) effectué',
),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
}
}
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
),
child: const Text('Valider',
style: TextStyle(color: Colors.white)),
),
],
);
},
),
);
}
void _viewEquipmentDetails(EquipmentModel equipment) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EquipmentDetailPage(equipment: equipment),
),
);
}
/// Scanner un QR Code et ouvrir la vue de détail correspondante
Future<void> _scanQRCode() async {
try {
// Ouvrir le scanner
final scannedCode = await showDialog<String>(
context: context,
builder: (context) => const QRCodeScannerDialog(),
);
if (scannedCode == null || scannedCode.isEmpty) {
return; // L'utilisateur a annulé
}
if (!mounted) return;
// Afficher un indicateur de chargement
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(color: AppColors.rouge),
),
);
// Rechercher d'abord dans les équipements
final equipmentProvider = context.read<EquipmentProvider>();
await equipmentProvider.ensureLoaded();
final equipment = equipmentProvider.allEquipment.firstWhere(
(eq) => eq.id == scannedCode,
orElse: () => EquipmentModel(
id: '',
name: '',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
if (mounted) {
Navigator.of(context).pop(); // Fermer l'indicateur
}
if (equipment.id.isNotEmpty) {
// Équipement trouvé
if (mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EquipmentDetailPage(equipment: equipment),
),
);
}
return;
}
// Si pas trouvé dans les équipements, chercher dans les conteneurs
final containerProvider = context.read<ContainerProvider>();
if (containerProvider.containers.isEmpty) {
await containerProvider.loadContainers();
}
final container = containerProvider.containers.firstWhere(
(c) => c.id == scannedCode,
orElse: () => ContainerModel(
id: '',
name: '',
type: ContainerType.flightCase,
status: EquipmentStatus.available,
equipmentIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
if (container.id.isNotEmpty) {
// Conteneur trouvé
if (mounted) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ContainerDetailPage(container: container),
),
);
}
return;
}
// Rien trouvé
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
backgroundColor: Colors.orange,
),
);
}
} catch (e) {
DebugLog.error('[EquipmentManagementPage] Error scanning QR code', e);
if (mounted) {
// Fermer l'indicateur si ouvert
Navigator.of(context).popUntil((route) => route.isFirst);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors du scan : ${e.toString()}'),
backgroundColor: Colors.red,
),
);
}
}
}
}