feat: Refonte de la checklist de préparation avec gestion des manquants et des containers

Cette mise à jour refond entièrement l'interface et la logique de la checklist de préparation d'événement. Elle introduit la notion d'équipements "manquants", une gestion visuelle des containers et de leur contenu, et une logique plus fine pour le suivi des quantités et des statuts à chaque étape.

**Features et Améliorations :**

-   **Gestion des Équipements Manquants :**
    -   Le modèle `EventEquipment` a été enrichi pour tracer si un équipement est manquant à chaque étape (`isMissingAtPreparation`, `isMissingAtLoading`, etc.).
    -   Un équipement non validé lors de la confirmation d'une étape est désormais marqué comme "manquant" pour les étapes suivantes.
    -   Les équipements qui étaient manquants à l'étape précédente sont maintenant visuellement mis en évidence avec une bordure et une icône orange, et une confirmation est demandée pour les valider.

-   **Refonte de la Checklist (UI/UX) :**
    -   **Groupement par Container :** La checklist affiche désormais les containers comme des en-têtes de groupe. Les équipements qu'ils contiennent sont listés en dessous, avec une indentation visuelle.
    -   **Validation Groupée :** Il est possible de valider tous les équipements d'un container en un seul clic sur l'en-tête du container.
    -   **Nouveau Widget `ContainerChecklistItem` :** Créé pour afficher un container et ses équipements enfants dans la checklist.
    -   **Refonte de `EquipmentChecklistItem` :** Le widget a été entièrement revu pour un design plus clair, une meilleure gestion des états (validé, manquant), et un affichage compact pour les équipements enfants.

-   **Logique de Suivi Améliorée :**
    -   **Quantités par Étape :** Le modèle `EventEquipment` et l'interface de préparation permettent maintenant de suivre les quantités réelles à chaque étape (`quantityAtPreparation`, `quantityAtLoading`, etc.), au lieu d'une seule quantité de retour.
    -   **Marquage Automatique des "Perdus" :** À l'étape finale du retour, un équipement qui était présent au départ mais qui est maintenant manquant sera automatiquement marqué avec le statut "lost" dans la base de données.
    -   **Flux de Validation :** Le processus de confirmation distingue désormais la validation de tous les équipements et la confirmation de l'état actuel (y compris les manquants).

-   **Export ICS Enrichi :**
    -   L'export ICS inclut désormais les noms résolus des utilisateurs (main d'œuvre) pour plus de clarté, en plus des détails de l'événement.
    -   Le contenu généré mentionne la version de l'application.
This commit is contained in:
ElPoyo
2026-01-15 12:05:37 +01:00
parent b30ae0f10a
commit 60d0e1c6c4
10 changed files with 885 additions and 336 deletions

View File

@@ -155,8 +155,27 @@ class _EventDetailsHeaderState extends State<EventDetailsHeader> {
),
);
// Générer le contenu ICS
final icsContent = await IcsExportService.generateIcsContent(widget.event);
// Charger les utilisateurs pour résoudre leurs noms
final dataService = DataService(FirebaseFunctionsApiService());
final users = await dataService.getUsers();
// Créer une Map des IDs utilisateurs vers leurs noms complets
final Map<String, String> userNames = {};
for (final user in users) {
final userId = user['id'] as String?;
final firstName = user['firstName'] as String? ?? '';
final lastName = user['lastName'] as String? ?? '';
if (userId != null && (firstName.isNotEmpty || lastName.isNotEmpty)) {
userNames[userId] = '$firstName $lastName'.trim();
}
}
// Générer le contenu ICS avec le nom du type et les noms des utilisateurs
final icsContent = await IcsExportService.generateIcsContent(
widget.event,
eventTypeName: _eventTypeName ?? 'Non spécifié',
userNames: userNames, // Passer les noms des utilisateurs
);
final fileName = IcsExportService.generateFileName(widget.event);
// Créer un blob et télécharger le fichier

View File

@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart';
import 'package:em2rp/utils/colors.dart';
/// Widget pour afficher un container avec ses équipements enfants dans une checklist
class ContainerChecklistItem extends StatelessWidget {
final ContainerModel container;
final List<EquipmentModel> childEquipments;
final Map<String, EventEquipment> eventEquipmentsMap; // Map equipmentId -> EventEquipment
final ChecklistStep step;
final bool isValidated; // Tous les enfants validés
final Map<String, bool> childValidationStates;
final VoidCallback onToggleContainer; // Callback pour clic sur le container (valide tout)
final Function(String equipmentId) onToggleChild;
final Function(String equipmentId, int quantity)? onQuantityChanged;
final Map<String, bool> wasMissingBeforeMap; // Map des enfants manquants à l'étape précédente
const ContainerChecklistItem({
super.key,
required this.container,
required this.childEquipments,
required this.eventEquipmentsMap,
required this.step,
required this.isValidated,
required this.childValidationStates,
required this.onToggleContainer,
required this.onToggleChild,
this.onQuantityChanged,
required this.wasMissingBeforeMap,
});
@override
Widget build(BuildContext context) {
final validatedCount = childValidationStates.values.where((v) => v).length;
final totalCount = childEquipments.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header du container (cliquable pour tout valider)
Card(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 0),
elevation: 2,
color: isValidated ? Colors.green.shade50 : Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: isValidated ? Colors.green : AppColors.rouge.withValues(alpha: 0.3),
width: 2,
),
),
child: ListTile(
onTap: onToggleContainer, // Clic sur le container = valider tout son contenu
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.rouge.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
container.type.iconData,
color: AppColors.rouge,
size: 24,
),
),
title: Text(
container.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
subtitle: Text(
'$validatedCount / $totalCount équipement(s) validé(s)\nCliquer pour tout valider',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
),
),
trailing: Icon(
isValidated ? Icons.check_circle : Icons.inventory,
color: isValidated ? Colors.green : AppColors.rouge,
size: 28,
),
),
),
// Enfants (équipements) indentés
...childEquipments.map((equipment) {
final eventEquipment = eventEquipmentsMap[equipment.id];
if (eventEquipment == null) return const SizedBox.shrink();
return EquipmentChecklistItem(
equipment: equipment,
eventEquipment: eventEquipment,
step: step,
isValidated: childValidationStates[equipment.id] ?? false,
onToggle: () => onToggleChild(equipment.id),
onQuantityChanged: onQuantityChanged != null
? (qty) => onQuantityChanged!(equipment.id, qty)
: null,
isChild: true, // Affichage indenté et plus petit
wasMissingBefore: wasMissingBeforeMap[equipment.id] ?? false,
);
}),
],
);
}
}

View File

@@ -18,7 +18,9 @@ class EquipmentChecklistItem extends StatelessWidget {
final ChecklistStep step;
final bool isValidated; // État de validation (passé depuis le parent)
final VoidCallback onToggle;
final ValueChanged<int>? onReturnedQuantityChanged;
final ValueChanged<int>? onQuantityChanged; // Callback pour changer la quantité à cette étape
final bool isChild; // Indique si c'est un enfant de container (affichage indenté et plus petit)
final bool wasMissingBefore; // Était manquant à l'étape précédente
const EquipmentChecklistItem({
super.key,
@@ -27,110 +29,171 @@ class EquipmentChecklistItem extends StatelessWidget {
this.step = ChecklistStep.preparation,
required this.isValidated,
required this.onToggle,
this.onReturnedQuantityChanged,
this.onQuantityChanged,
this.isChild = false, // Par défaut, pas d'indentation
this.wasMissingBefore = false,
});
/// Retourne la quantité actuelle selon l'étape
int _getCurrentQuantity() {
switch (step) {
case ChecklistStep.preparation:
return eventEquipment.quantityAtPreparation ?? eventEquipment.quantity;
case ChecklistStep.loading:
return eventEquipment.quantityAtLoading ?? eventEquipment.quantityAtPreparation ?? eventEquipment.quantity;
case ChecklistStep.unloading:
return eventEquipment.quantityAtUnloading ?? eventEquipment.quantityAtLoading ?? eventEquipment.quantity;
case ChecklistStep.return_:
return eventEquipment.quantityAtReturn ?? eventEquipment.quantityAtUnloading ?? eventEquipment.quantity;
}
}
@override
Widget build(BuildContext context) {
final hasQuantity = equipment.hasQuantity;
final showQuantityInput = step == ChecklistStep.return_ && hasQuantity;
return Card(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 0),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: isValidated ? Colors.green : Colors.grey.shade300,
width: isValidated ? 2 : 1,
),
// Déterminer la quantité actuelle selon l'étape
final int currentQuantity = _getCurrentQuantity();
return Padding(
padding: EdgeInsets.only(
left: isChild ? 32.0 : 0.0, // Indentation pour les enfants
top: 4.0,
bottom: 4.0,
),
child: ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isValidated ? Colors.green.shade100 : Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
child: Card(
margin: EdgeInsets.zero,
elevation: isChild ? 0 : 1, // Pas d'élévation pour les enfants
color: wasMissingBefore
? Colors.orange.shade50
: (isChild ? Colors.grey.shade50 : Colors.white),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: wasMissingBefore
? Colors.orange
: (isValidated ? Colors.green : Colors.grey.shade300),
width: (isValidated || wasMissingBefore) ? 2 : 1,
),
child: IconButton(
icon: Icon(
isValidated ? Icons.check_circle : Icons.radio_button_unchecked,
color: isValidated ? Colors.green : Colors.grey,
),
child: ListTile(
dense: isChild, // Plus compact pour les enfants
contentPadding: EdgeInsets.symmetric(
horizontal: isChild ? 8.0 : 16.0,
vertical: isChild ? 4.0 : 8.0,
),
leading: Container(
width: isChild ? 32 : 40,
height: isChild ? 32 : 40,
decoration: BoxDecoration(
color: wasMissingBefore
? Colors.orange.shade100
: (isValidated ? Colors.green.shade100 : Colors.grey.shade100),
borderRadius: BorderRadius.circular(8),
),
onPressed: onToggle,
padding: EdgeInsets.zero,
),
),
title: Text(
equipment.name,
style: TextStyle(
fontWeight: FontWeight.w600,
decoration: isValidated ? TextDecoration.lineThrough : null,
color: isValidated ? Colors.grey : null,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (equipment.model != null)
Text(
equipment.model!,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
child: IconButton(
icon: Icon(
wasMissingBefore
? Icons.warning
: (isValidated ? Icons.check_circle : Icons.radio_button_unchecked),
color: wasMissingBefore
? Colors.orange
: (isValidated ? Colors.green : Colors.grey),
size: isChild ? 18 : 24,
),
if (hasQuantity) ...[
const SizedBox(height: 4),
Row(
children: [
Text(
'Quantité : ${eventEquipment.quantity}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.bleuFonce,
),
onPressed: onToggle,
padding: EdgeInsets.zero,
),
),
title: Text(
equipment.name,
style: TextStyle(
fontWeight: isChild ? FontWeight.w500 : FontWeight.w600,
fontSize: isChild ? 13 : 15,
decoration: isValidated ? TextDecoration.lineThrough : null,
color: isValidated ? Colors.grey : null,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (equipment.model != null)
Text(
equipment.model!,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
if (showQuantityInput && onReturnedQuantityChanged != null) ...[
const SizedBox(width: 16),
const Icon(Icons.arrow_forward, size: 12, color: Colors.grey),
const SizedBox(width: 8),
),
// Indicateur si manquant à l'étape précédente
if (wasMissingBefore)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
children: [
Icon(Icons.warning_amber, size: 14, color: Colors.orange),
const SizedBox(width: 4),
Text(
'Était manquant à l\'étape précédente',
style: TextStyle(
fontSize: 11,
color: Colors.orange.shade700,
fontWeight: FontWeight.w600,
),
),
],
),
),
// Quantité (éditable ou affichage seul)
if (hasQuantity) ...[
const SizedBox(height: 6),
Row(
children: [
Text(
'Retourné : ',
'Quantité : ',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
color: AppColors.bleuFonce,
),
),
SizedBox(
width: 80,
child: TextField(
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
if (onQuantityChanged != null)
SizedBox(
width: 60,
child: TextFormField(
initialValue: currentQuantity.toString(),
keyboardType: TextInputType.number,
style: const TextStyle(fontSize: 12),
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
),
),
hintText: '${eventEquipment.quantity}',
onChanged: (value) {
final qty = int.tryParse(value) ?? currentQuantity;
onQuantityChanged!(qty);
},
),
)
else
Text(
currentQuantity.toString(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.bleuFonce,
),
keyboardType: TextInputType.number,
onChanged: (value) {
final qty = int.tryParse(value) ?? eventEquipment.quantity;
onReturnedQuantityChanged!(qty);
},
style: const TextStyle(fontSize: 12),
),
),
],
],
),
),
],
],
],
),
trailing: isValidated
),
trailing: isValidated
? Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
@@ -154,8 +217,9 @@ class EquipmentChecklistItem extends StatelessWidget {
),
)
: null,
),
);
), // Fin ListTile
), // Fin Card
); // Fin Padding
}
}

View File

@@ -1,173 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:em2rp/models/equipment_model.dart';
/// Widget pour afficher un équipement avec checkbox de validation
class EquipmentChecklistItem extends StatelessWidget {
final EquipmentModel equipment;
final bool isValidated;
final ValueChanged<bool> onValidate;
final bool isReturnMode;
final int? quantity;
final int? returnedQuantity;
final ValueChanged<int>? onReturnedQuantityChanged;
const EquipmentChecklistItem({
super.key,
required this.equipment,
required this.isValidated,
required this.onValidate,
this.isReturnMode = false,
this.quantity,
this.returnedQuantity,
this.onReturnedQuantityChanged,
});
bool get _isConsumable =>
equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: isValidated ? 0 : 2,
color: isValidated ? Colors.green.shade50 : Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: isValidated ? Colors.green : Colors.grey.shade300,
width: isValidated ? 2 : 1,
),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Checkbox de validation
Checkbox(
value: isValidated,
onChanged: (value) => onValidate(value ?? false),
activeColor: Colors.green,
),
const SizedBox(width: 12),
// Icône de l'équipement
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: equipment.category.color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: equipment.category.getIcon(
size: 24,
color: equipment.category.color,
),
),
const SizedBox(width: 12),
// Informations de l'équipement
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nom/ID
Text(
equipment.id,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 4),
// Marque/Modèle
if (equipment.brand != null || equipment.model != null)
Text(
'${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim(),
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 4),
// Quantité assignée (consommables uniquement)
if (_isConsumable && quantity != null)
Text(
'Quantité assignée : $quantity',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
// Champ de quantité retournée (mode retour + consommables)
if (isReturnMode && _isConsumable && onReturnedQuantityChanged != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Text(
'Quantité retournée :',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
),
),
const SizedBox(width: 8),
SizedBox(
width: 80,
child: TextField(
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
hintText: quantity?.toString() ?? '0',
),
controller: TextEditingController(
text: returnedQuantity?.toString() ?? quantity?.toString() ?? '0',
),
onChanged: (value) {
final intValue = int.tryParse(value) ?? 0;
if (onReturnedQuantityChanged != null) {
onReturnedQuantityChanged!(intValue);
}
},
),
),
],
),
),
],
),
),
// Icône de statut
if (isValidated)
const Icon(
Icons.check_circle,
color: Colors.green,
size: 28,
),
],
),
),
);
}
}

View File

@@ -379,8 +379,10 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
equipmentId: eq.equipmentId,
quantity: eq.quantity, // Utiliser la nouvelle quantité
isPrepared: updatedEquipment[existingIndex].isPrepared,
isLoaded: updatedEquipment[existingIndex].isLoaded,
isUnloaded: updatedEquipment[existingIndex].isUnloaded,
isReturned: updatedEquipment[existingIndex].isReturned,
returnedQuantity: updatedEquipment[existingIndex].returnedQuantity,
quantityAtReturn: updatedEquipment[existingIndex].quantityAtReturn,
);
} else {
// L'équipement n'existe pas : l'ajouter