feat: recherche d'événements et gestion avancée de la suppression d'équipement

- **Recherche d'événements** : Ajout d'une fonctionnalité de recherche (titre, description, lieu) dans le calendrier et d'une nouvelle fonction Cloud `searchEvents` avec gestion des permissions.
- **Suppression d'équipement avec forçage** :
    - Mise à jour de la fonction Cloud `deleteEquipment` pour détecter les assignations à des événements futurs.
    - Ajout d'une option `forceDelete` pour passer outre les conflits d'assignation.
    - Création de `EquipmentDeleteUtils` pour gérer uniformément les dialogues de confirmation et les erreurs de conflit (HTTP 409).
    - Intégration de la logique de suppression sécurisée dans `EquipmentDetailPage` et `EquipmentManagementPage`.
- **Calendrier** :
    - Refonte de l'interface mobile pour intégrer la barre de recherche.
    - Optimisation du chargement des événements lors de la sélection d'un résultat de recherche (lazy loading du mois concerné).
    - Amélioration de la stabilité de la sélection d'événements et du filtrage par utilisateur.
- **Services & Providers** :
    - Amélioration de la gestion des erreurs dans `ApiService` pour faciliter le re-throw des exceptions personnalisées.
    - Ajout du support de la suppression forcée dans `DataService` et `EquipmentProvider`.
- **Refactoring** : Nettoyage du code, amélioration du formatage et ajout de logs de debug dans les services de données et d'équipements.
This commit is contained in:
ElPoyo
2026-04-22 12:21:13 +02:00
parent 0551f0b9c1
commit eac103491f
14 changed files with 1309 additions and 341 deletions
+231 -165
View File
@@ -16,6 +16,7 @@ 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/utils/equipment_delete_utils.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';
@@ -28,7 +29,6 @@ class EquipmentManagementPage extends StatefulWidget {
_EquipmentManagementPageState();
}
class _EquipmentManagementPageState extends State<EquipmentManagementPage>
with SelectionModeMixin<EquipmentManagementPage> {
final TextEditingController _searchController = TextEditingController();
@@ -66,7 +66,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
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
@@ -76,7 +75,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
_isLoadingMore = false;
}).catchError((error) {
_isLoadingMore = false;
DebugLog.error('[EquipmentManagementPage] Error loading next page', error);
DebugLog.error(
'[EquipmentManagementPage] Error loading next page', error);
});
}
}
@@ -456,11 +456,13 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
Widget _buildEquipmentList() {
return Consumer<EquipmentProvider>(
builder: (context, provider, child) {
DebugLog.info('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
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');
DebugLog.info(
'[EquipmentManagementPage] Showing initial loading indicator');
return const Center(child: CircularProgressIndicator());
}
@@ -490,7 +492,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
);
}
DebugLog.info('[EquipmentManagementPage] Building list with ${equipments.length} items');
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);
@@ -526,124 +529,127 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
// ✅ 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,
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),
],
// Afficher la quantité disponible pour les consommables/câbles
if (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable) ...[
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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),
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,
),
),
],
),
onTap: isSelectionMode
? () => toggleItemSelection(equipment.id)
: () => _viewEquipmentDetails(equipment),
),
)
);
// 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) {
@@ -705,7 +711,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
);
}
// Actions
void _createNewEquipment() {
Navigator.push(
@@ -726,39 +731,64 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
}
void _deleteEquipment(EquipmentModel equipment) {
final pageContext = context;
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
id: equipment.id,
name: equipment.name,
);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
content: Text('Voulez-vous vraiment supprimer "${equipment.name}" ?'),
context: pageContext,
builder: (dialogContext) => AlertDialog(
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
content: Text(
EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
equipmentLabel,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
onPressed: () => Navigator.pop(dialogContext),
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
Navigator.pop(dialogContext);
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
final provider = pageContext.read<EquipmentProvider>();
try {
await context
.read<EquipmentProvider>()
.deleteEquipment(equipment.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Équipement supprimé avec succès')),
);
final deleted =
await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck(
context: pageContext,
equipmentLabel: equipmentLabel,
deleteEquipment: ({bool forceDelete = false}) {
return provider.deleteEquipment(
equipment.id,
forceDelete: forceDelete,
);
},
);
if (!deleted) {
return;
}
scaffoldMessenger.showSnackBar(
const SnackBar(
content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
backgroundColor: Colors.green,
),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
}
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
),
),
);
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
),
],
),
@@ -768,46 +798,78 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
void _deleteSelectedEquipment() async {
if (!hasSelection) return;
final pageContext = context;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmer la suppression'),
context: pageContext,
builder: (dialogContext) => AlertDialog(
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
content: Text(
'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?',
EquipmentDeleteUtils.buildBulkDeleteConfirmationMessage(
selectedCount,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
onPressed: () => Navigator.pop(dialogContext),
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
Navigator.pop(dialogContext);
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
final provider = pageContext.read<EquipmentProvider>();
try {
final provider = context.read<EquipmentProvider>();
final equipmentById = {
for (final equipment
in provider.equipment)
equipment.id: equipment,
};
var deletedCount = 0;
for (final id in selectedIds) {
await provider.deleteEquipment(id);
final label = EquipmentDeleteUtils.resolveEquipmentLabel(
id: id,
name: equipmentById[id]?.name,
);
final deleted = await EquipmentDeleteUtils
.deleteWithFutureAssignmentCheck(
context: pageContext,
equipmentLabel: label,
deleteEquipment: ({bool forceDelete = false}) {
return provider.deleteEquipment(
id,
forceDelete: forceDelete,
);
},
);
if (deleted) {
deletedCount++;
}
}
disableSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'$selectedCount équipement(s) supprimé(s) avec succès'),
backgroundColor: Colors.green,
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
EquipmentDeleteUtils.buildBulkDeleteSuccessMessage(
deletedCount,
),
),
);
}
backgroundColor: Colors.green,
),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
}
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
),
),
);
}
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
),
],
),
@@ -853,7 +915,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
if (mounted) {
showDialog(
context: context,
builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first),
builder: (context) =>
QRCodeDialog.forEquipment(selectedEquipment.first),
);
}
} else {
@@ -1046,7 +1109,9 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
updatedAt: DateTime.now(),
);
await context.read<EquipmentProvider>().updateEquipment(updatedEquipment);
await context
.read<EquipmentProvider>()
.updateEquipment(updatedEquipment);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -1184,7 +1249,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
content: Text(
'Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
backgroundColor: Colors.orange,
),
);