feat: Refactor event equipment management with advanced selection and conflict detection

This commit introduces a complete overhaul of how equipment is assigned to events, focusing on an enhanced user experience, advanced selection capabilities, and robust conflict detection.

**Key Features & Enhancements:**

-   **Advanced Equipment Selection UI (`EquipmentSelectionDialog`):**
    -   New full-screen dialog to select equipment and containers ("boîtes") for an event.
    -   Hierarchical view showing containers and a flat list of all individual equipment.
    -   Real-time search and filtering by equipment category.
    -   Side panel summarizing the current selection and providing recommendations for containers based on selected equipment.
    -   Supports quantity selection for consumables and cables.

-   **Conflict Detection & Management (`EventAvailabilityService`):**
    -   A new service (`EventAvailabilityService`) checks for equipment availability against other events based on the selected date range.
    -   The selection dialog visually highlights equipment and containers with scheduling conflicts (e.g., already used, partially unavailable).
    -   A dedicated conflict resolution dialog (`EquipmentConflictDialog`) appears if conflicting items are selected, allowing the user to either remove them or force the assignment.

-   **Integrated Event Form (`EventAssignedEquipmentSection`):**
    -   The event creation/editing form now includes a new section for managing assigned equipment.
    -   It clearly displays assigned containers and standalone equipment, showing the composition of each container.
    -   Integrates the new selection dialog, ensuring all assignments are checked for conflicts before being saved.

-   **Event Preparation & Return Workflow (`EventPreparationPage`):**
    -   New page (`EventPreparationPage`) for managing the check-out (preparation) and check-in (return) of equipment for an event.
    -   Provides a checklist of all assigned equipment.
    -   Users can validate each item, with options to "validate all" or finalize with missing items.
    -   Includes a dialog (`MissingEquipmentDialog`) to handle discrepancies.
    -   Supports tracking returned quantities for consumables.

**Data Model and Other Changes:**

-   The `EventModel` now includes `assignedContainers` to explicitly link containers to an event.
-   `EquipmentAssociatedEventsSection` on the equipment detail page is now functional, displaying current, upcoming, and past events for that item.
-   Added deployment and versioning scripts (`scripts/deploy.js`, `scripts/increment_version.js`, `scripts/toggle_env.js`) to automate the release process.
-   Introduced an application version display in the main drawer (`AppVersion`).
This commit is contained in:
ElPoyo
2025-11-30 20:33:03 +01:00
parent e59e3e6316
commit 08f046c89c
31 changed files with 4955 additions and 46 deletions

View File

@@ -0,0 +1,173 @@
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

@@ -0,0 +1,288 @@
import 'package:flutter/material.dart';
import 'package:em2rp/services/event_availability_service.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:intl/intl.dart';
/// Dialog affichant les conflits de disponibilité du matériel
class EquipmentConflictDialog extends StatefulWidget {
final Map<String, List<AvailabilityConflict>> conflicts;
const EquipmentConflictDialog({
super.key,
required this.conflicts,
});
@override
State<EquipmentConflictDialog> createState() => _EquipmentConflictDialogState();
}
class _EquipmentConflictDialogState extends State<EquipmentConflictDialog> {
final Set<String> _removedEquipmentIds = {};
int get totalConflicts => widget.conflicts.values.fold(0, (sum, list) => sum + list.length);
int get remainingConflicts => widget.conflicts.entries
.where((entry) => !_removedEquipmentIds.contains(entry.key))
.fold(0, (sum, entry) => sum + entry.value.length);
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat('dd/MM/yyyy');
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
constraints: const BoxConstraints(maxWidth: 700, maxHeight: 700),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// En-tête avec icône warning
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade100,
shape: BoxShape.circle,
),
child: Icon(
Icons.warning_amber_rounded,
size: 32,
color: Colors.orange.shade700,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Conflits de disponibilité détectés',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'$remainingConflicts conflit(s) sur $totalConflicts équipement(s)',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade700,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop('cancel'),
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
// Liste des conflits
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: widget.conflicts.length,
itemBuilder: (context, index) {
final entry = widget.conflicts.entries.elementAt(index);
final equipmentId = entry.key;
final conflictsList = entry.value;
final isRemoved = _removedEquipmentIds.contains(equipmentId);
if (conflictsList.isEmpty) return const SizedBox.shrink();
final firstConflict = conflictsList.first;
return Opacity(
opacity: isRemoved ? 0.4 : 1.0,
child: Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: isRemoved ? 0 : 2,
color: isRemoved ? Colors.grey.shade200 : null,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nom de l'équipement
Row(
children: [
Icon(
Icons.inventory_2,
color: isRemoved ? Colors.grey : AppColors.rouge,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
firstConflict.equipmentName,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
decoration: isRemoved ? TextDecoration.lineThrough : null,
),
),
),
if (isRemoved)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'RETIRÉ',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 12),
// Liste des événements en conflit
...conflictsList.map((conflict) {
return Padding(
padding: const EdgeInsets.only(bottom: 8, left: 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.event,
size: 16,
color: Colors.grey.shade600,
),
const SizedBox(width: 6),
Expanded(
child: Text(
conflict.conflictingEvent.name,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Colors.grey.shade800,
),
),
),
],
),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.only(left: 22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${dateFormat.format(conflict.conflictingEvent.startDateTime)}${dateFormat.format(conflict.conflictingEvent.endDateTime)}',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
Text(
'Chevauchement : ${conflict.overlapDays} jour(s)',
style: TextStyle(
fontSize: 12,
color: Colors.orange.shade700,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
);
}).toList(),
// Boutons d'action par équipement
if (!isRemoved)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
const Spacer(),
OutlinedButton.icon(
onPressed: () {
setState(() {
_removedEquipmentIds.add(equipmentId);
});
},
icon: const Icon(Icons.remove_circle_outline, size: 16),
label: const Text('Retirer'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
side: const BorderSide(color: Colors.red),
),
),
],
),
),
],
),
),
),
);
},
),
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 16),
// Boutons d'action globaux
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop('cancel'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: const Text('Annuler tout'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: remainingConflicts == 0
? () => Navigator.of(context).pop('force_removed')
: () => Navigator.of(context).pop('force_all'),
style: ElevatedButton.styleFrom(
backgroundColor: remainingConflicts == 0 ? Colors.green : Colors.orange,
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: Text(
remainingConflicts == 0
? 'Valider sans les retirés'
: 'Forcer malgré les conflits',
style: const TextStyle(color: Colors.white),
),
),
),
],
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/equipment_model.dart';
/// Dialog affichant la liste des équipements non validés
class MissingEquipmentDialog extends StatelessWidget {
final List<EquipmentModel> missingEquipments;
final String eventId;
final bool isReturnMode;
const MissingEquipmentDialog({
super.key,
required this.missingEquipments,
required this.eventId,
this.isReturnMode = false,
});
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
constraints: const BoxConstraints(maxWidth: 500, maxHeight: 600),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Icône d'avertissement
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.orange.shade100,
shape: BoxShape.circle,
),
child: Icon(
Icons.warning_amber_rounded,
size: 48,
color: Colors.orange.shade700,
),
),
const SizedBox(height: 16),
// Titre
Text(
isReturnMode
? 'Équipements non retournés'
: 'Équipements non préparés',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
// Description
Text(
isReturnMode
? 'Les équipements suivants n\'ont pas été marqués comme retournés :'
: 'Les équipements suivants n\'ont pas été marqués comme préparés :',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade700,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Liste des équipements manquants
Flexible(
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: ListView.separated(
shrinkWrap: true,
itemCount: missingEquipments.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final equipment = missingEquipments[index];
return ListTile(
dense: true,
leading: equipment.category.getIcon(
size: 20,
color: equipment.category.color,
),
title: Text(
equipment.id,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: equipment.brand != null || equipment.model != null
? Text('${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim())
: null,
);
},
),
),
),
const SizedBox(height: 24),
// Boutons d'action
Column(
children: [
// Bouton 1 : Confirmer malgré les manquants (primaire - rouge)
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => Navigator.of(context).pop('confirm_with_missing'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.shade600,
padding: const EdgeInsets.symmetric(vertical: 14),
),
icon: const Icon(Icons.check_circle_outline, color: Colors.white),
label: Text(
isReturnMode
? 'Confirmer malgré les manquants'
: 'Valider malgré les manquants',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 12),
// Bouton 2 : Marquer comme validés (secondaire - vert)
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => Navigator.of(context).pop('validate_missing'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
padding: const EdgeInsets.symmetric(vertical: 14),
),
icon: const Icon(Icons.done_all, color: Colors.white),
label: Text(
isReturnMode
? 'Marquer tout comme retourné'
: 'Marquer tout comme préparé',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 12),
// Bouton 3 : Retourner à la liste (tertiaire - outline)
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => Navigator.of(context).pop(),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
side: BorderSide(color: Colors.grey.shade400),
),
icon: Icon(Icons.arrow_back, color: Colors.grey.shade700),
label: Text(
'Retourner à la liste',
style: TextStyle(
color: Colors.grey.shade700,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,226 @@
import 'package:flutter/material.dart';
import 'package:em2rp/utils/colors.dart';
/// Dialog de succès avec animation de camion
class PreparationSuccessDialog extends StatefulWidget {
final bool isReturnMode;
const PreparationSuccessDialog({
super.key,
this.isReturnMode = false,
});
@override
State<PreparationSuccessDialog> createState() => _PreparationSuccessDialogState();
}
class _PreparationSuccessDialogState extends State<PreparationSuccessDialog>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _truckAnimation;
late Animation<double> _fadeAnimation;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 2500),
vsync: this,
);
// Animation du camion qui part (translation)
_truckAnimation = Tween<double>(
begin: 0.0,
end: 1.5,
).animate(CurvedAnimation(
parent: _controller,
curve: const Interval(0.3, 1.0, curve: Curves.easeInBack),
));
// Animation de fade out
_fadeAnimation = Tween<double>(
begin: 1.0,
end: 0.0,
).animate(CurvedAnimation(
parent: _controller,
curve: const Interval(0.7, 1.0, curve: Curves.easeOut),
));
// Animation de scale pour le check
_scaleAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.3, curve: Curves.elasticOut),
));
_controller.forward();
// Auto-fermer après l'animation
Future.delayed(const Duration(milliseconds: 2500), () {
if (mounted) {
Navigator.of(context).pop();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: Container(
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Animation du check qui pop
Transform.scale(
scale: _scaleAnimation.value,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
child: const Icon(
Icons.check,
color: Colors.white,
size: 50,
),
),
),
const SizedBox(height: 24),
// Texte
Text(
widget.isReturnMode
? 'Retour validé !'
: 'Préparation validée !',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.green,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// Animation du camion avec pneus qui crissent
SizedBox(
height: 100,
child: Stack(
children: [
// Traces de pneus (lignes qui apparaissent)
if (_truckAnimation.value > 0.1)
Positioned(
left: 0,
right: MediaQuery.of(context).size.width * 0.3,
bottom: 30,
child: CustomPaint(
painter: TireMarksPainter(
progress: (_truckAnimation.value - 0.1).clamp(0.0, 1.0),
),
),
),
// Camion qui part
Positioned(
left: MediaQuery.of(context).size.width * _truckAnimation.value - 100,
bottom: 20,
child: Transform.rotate(
angle: _truckAnimation.value > 0.5 ? -0.1 : 0,
child: const Icon(
Icons.local_shipping,
size: 60,
color: AppColors.rouge,
),
),
),
],
),
),
const SizedBox(height: 16),
Text(
widget.isReturnMode
? 'Le matériel est de retour au dépôt'
: 'Le matériel est prêt pour l\'événement',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
textAlign: TextAlign.center,
),
],
),
),
);
},
),
);
}
}
/// Custom painter pour dessiner les traces de pneus
class TireMarksPainter extends CustomPainter {
final double progress;
TireMarksPainter({required this.progress});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.grey.shade400
..strokeWidth = 2
..style = PaintingStyle.stroke;
final dashWidth = 10.0;
final dashSpace = 5.0;
final maxWidth = size.width * progress;
// Dessiner deux lignes de traces (pour les deux roues)
for (var i = 0; i < 2; i++) {
final y = i * 15.0;
var startX = 0.0;
while (startX < maxWidth) {
final endX = (startX + dashWidth).clamp(0.0, maxWidth);
canvas.drawLine(
Offset(startX, y),
Offset(endX, y),
paint,
);
startX += dashWidth + dashSpace;
}
}
}
@override
bool shouldRepaint(TireMarksPainter oldDelegate) {
return oldDelegate.progress != progress;
}
}