feat: add current events section for equipment with dynamic status calculation

This commit is contained in:
ElPoyo
2026-01-06 12:13:09 +01:00
parent 25d395b41a
commit fb6a271f66
12 changed files with 773 additions and 124 deletions

View File

@@ -87,40 +87,45 @@ class _EventPreparationButtonsState extends State<EventPreparationButtons> {
children: [
// Bouton de l'étape actuelle
if (!isCompleted)
ElevatedButton.icon(
onPressed: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EventPreparationPage(
initialEvent: event,
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: ElevatedButton.icon(
onPressed: () async {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => EventPreparationPage(
initialEvent: event,
),
),
);
// Si la validation a réussi, le StreamBuilder se rechargera automatiquement
if (result == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Étape validée avec succès'),
backgroundColor: Colors.green,
),
);
}
},
icon: Icon(buttonIcon),
label: Text(
buttonText,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
);
// Si la validation a réussi, le StreamBuilder se rechargera automatiquement
if (result == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Étape validée avec succès'),
backgroundColor: Colors.green,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.bleuFonce,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
);
}
},
icon: Icon(buttonIcon),
label: Text(
buttonText,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.bleuFonce,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),

View File

@@ -6,9 +6,8 @@ import 'package:em2rp/utils/colors.dart';
import 'package:intl/intl.dart';
enum EventFilter {
current, // Événements en cours (préparés mais pas encore retournés)
upcoming, // Événements à venir
past, // Événements passés
past, // Événements passés
}
/// Widget pour afficher les événements associés à un équipement
@@ -27,7 +26,7 @@ class EquipmentAssociatedEventsSection extends StatefulWidget {
class _EquipmentAssociatedEventsSectionState
extends State<EquipmentAssociatedEventsSection> {
EventFilter _selectedFilter = EventFilter.current;
EventFilter _selectedFilter = EventFilter.upcoming;
List<EventModel> _events = [];
bool _isLoading = true;
@@ -41,38 +40,80 @@ class _EquipmentAssociatedEventsSectionState
setState(() => _isLoading = true);
try {
// Récupérer TOUS les événements car on ne peut pas faire arrayContains sur un objet
final eventsSnapshot = await FirebaseFirestore.instance
.collection('events')
.where('assignedEquipment',
arrayContains: {'equipmentId': widget.equipment.id})
.get();
final events = eventsSnapshot.docs
.map((doc) => EventModel.fromMap(doc.data(), doc.id))
.toList();
final events = <EventModel>[];
// Récupérer toutes les boîtes pour vérifier leur contenu
final containersSnapshot = await FirebaseFirestore.instance
.collection('containers')
.get();
final containersWithEquipment = <String>[];
for (var containerDoc in containersSnapshot.docs) {
try {
final data = containerDoc.data();
final equipmentIds = List<String>.from(data['equipmentIds'] ?? []);
if (equipmentIds.contains(widget.equipment.id)) {
containersWithEquipment.add(containerDoc.id);
}
} catch (e) {
print('[EquipmentAssociatedEventsSection] Error parsing container ${containerDoc.id}: $e');
}
}
// Filtrer manuellement les événements qui contiennent cet équipement
for (var doc in eventsSnapshot.docs) {
try {
final event = EventModel.fromMap(doc.data(), doc.id);
// Vérifier si l'équipement est directement assigné
final hasEquipmentDirectly = event.assignedEquipment.any(
(eq) => eq.equipmentId == widget.equipment.id,
);
// Vérifier si l'équipement est dans une boîte assignée à l'événement
final hasEquipmentInAssignedContainer = event.assignedContainers.any(
(containerId) => containersWithEquipment.contains(containerId),
);
if (hasEquipmentDirectly || hasEquipmentInAssignedContainer) {
events.add(event);
}
} catch (e) {
print('[EquipmentAssociatedEventsSection] Error parsing event ${doc.id}: $e');
}
}
// Filtrer selon le statut
final now = DateTime.now();
final filteredEvents = events.where((event) {
switch (_selectedFilter) {
case EventFilter.current:
// Événement en cours = préparation complétée ET retour pas encore complété
return (event.preparationStatus == PreparationStatus.completed ||
event.preparationStatus ==
PreparationStatus.completedWithMissing) &&
(event.returnStatus == null ||
event.returnStatus == ReturnStatus.notStarted ||
event.returnStatus == ReturnStatus.inProgress);
// Un événement est EN COURS dès que la préparation est complétée
// et jusqu'à ce que le retour soit complété
final isPrepared = event.preparationStatus == PreparationStatus.completed ||
event.preparationStatus == PreparationStatus.completedWithMissing;
final isReturned = event.returnStatus == ReturnStatus.completed ||
event.returnStatus == ReturnStatus.completedWithMissing;
final isInProgress = isPrepared && !isReturned;
if (isInProgress) {
return false; // Les événements en cours sont affichés dans une autre section
}
switch (_selectedFilter) {
case EventFilter.upcoming:
// Événements à venir = date de début dans le futur OU préparation pas encore faite
return event.startDateTime.isAfter(now) ||
event.preparationStatus == PreparationStatus.notStarted;
// Événements à venir = date de début dans le futur
return event.startDateTime.isAfter(now);
case EventFilter.past:
// Événements passés = retour complété
return event.returnStatus == ReturnStatus.completed ||
event.returnStatus == ReturnStatus.completedWithMissing;
// Événements passés = date de fin passée
return event.endDateTime.isBefore(now);
}
}).toList();
@@ -98,8 +139,6 @@ class _EquipmentAssociatedEventsSectionState
String _getFilterLabel(EventFilter filter) {
switch (filter) {
case EventFilter.current:
return 'En cours';
case EventFilter.upcoming:
return 'À venir';
case EventFilter.past:
@@ -109,8 +148,6 @@ class _EquipmentAssociatedEventsSectionState
IconData _getFilterIcon(EventFilter filter) {
switch (filter) {
case EventFilter.current:
return Icons.play_circle;
case EventFilter.upcoming:
return Icons.upcoming;
case EventFilter.past:
@@ -134,7 +171,7 @@ class _EquipmentAssociatedEventsSectionState
const SizedBox(width: 8),
Expanded(
child: Text(
'Événements associés',
'Événements passés / à venir',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -225,11 +262,28 @@ class _EquipmentAssociatedEventsSectionState
Widget _buildEventCard(EventModel event) {
final dateFormat = DateFormat('dd/MM/yyyy HH:mm');
final isInProgress = (event.preparationStatus == PreparationStatus.completed ||
event.preparationStatus == PreparationStatus.completedWithMissing) &&
(event.returnStatus == null ||
event.returnStatus == ReturnStatus.notStarted ||
event.returnStatus == ReturnStatus.inProgress);
// Un événement est en cours dès que la préparation est complétée
// et jusqu'à ce que le retour soit complété
final isPrepared = event.preparationStatus == PreparationStatus.completed ||
event.preparationStatus == PreparationStatus.completedWithMissing;
final isReturned = event.returnStatus == ReturnStatus.completed ||
event.returnStatus == ReturnStatus.completedWithMissing;
final isInProgress = isPrepared && !isReturned;
// Trouver la quantité utilisée pour les consommables/câbles
int? usedQuantity;
if (widget.equipment.hasQuantity) {
final assignedEquipment = event.assignedEquipment.firstWhere(
(eq) => eq.equipmentId == widget.equipment.id,
orElse: () => EventEquipment(equipmentId: ''),
);
if (assignedEquipment.equipmentId.isNotEmpty) {
usedQuantity = assignedEquipment.quantity;
}
}
return Card(
margin: const EdgeInsets.only(bottom: 12),
@@ -290,16 +344,46 @@ class _EquipmentAssociatedEventsSectionState
children: [
const Icon(Icons.calendar_today, size: 14, color: Colors.grey),
const SizedBox(width: 4),
Text(
'${dateFormat.format(event.startDateTime)}${dateFormat.format(event.endDateTime)}',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
Expanded(
child: Text(
'${dateFormat.format(event.startDateTime)}${dateFormat.format(event.endDateTime)}',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
),
),
),
],
),
// Quantité utilisée pour consommables/câbles
if (usedQuantity != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.inventory, size: 14, color: Colors.blue),
const SizedBox(width: 6),
Text(
'Quantité utilisée: $usedQuantity',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
],
),
),
],
const SizedBox(height: 8),
// Statuts de préparation et retour
@@ -316,31 +400,6 @@ class _EquipmentAssociatedEventsSectionState
),
],
),
// Boutons d'action
if (isInProgress && _selectedFilter == EventFilter.current) ...[
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Navigator.pushNamed(
context,
'/event_preparation',
arguments: event.id,
);
},
icon: const Icon(Icons.logout, size: 16),
label: const Text('Check-out'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.rouge,
),
),
),
],
),
],
],
),
),

View File

@@ -0,0 +1,284 @@
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:intl/intl.dart';
/// Widget pour afficher les événements EN COURS utilisant cet équipement
class EquipmentCurrentEventsSection extends StatefulWidget {
final EquipmentModel equipment;
const EquipmentCurrentEventsSection({
super.key,
required this.equipment,
});
@override
State<EquipmentCurrentEventsSection> createState() =>
_EquipmentCurrentEventsSectionState();
}
class _EquipmentCurrentEventsSectionState
extends State<EquipmentCurrentEventsSection> {
List<EventModel> _events = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadCurrentEvents();
}
Future<void> _loadCurrentEvents() async {
setState(() => _isLoading = true);
try {
// Récupérer TOUS les événements
final eventsSnapshot = await FirebaseFirestore.instance
.collection('events')
.get();
final events = <EventModel>[];
// Récupérer toutes les boîtes pour vérifier leur contenu
final containersSnapshot = await FirebaseFirestore.instance
.collection('containers')
.get();
final containersWithEquipment = <String>[];
for (var containerDoc in containersSnapshot.docs) {
try {
final data = containerDoc.data();
final equipmentIds = List<String>.from(data['equipmentIds'] ?? []);
if (equipmentIds.contains(widget.equipment.id)) {
containersWithEquipment.add(containerDoc.id);
}
} catch (e) {
print('[EquipmentCurrentEventsSection] Error parsing container ${containerDoc.id}: $e');
}
}
// Filtrer les événements en cours
for (var doc in eventsSnapshot.docs) {
try {
final event = EventModel.fromMap(doc.data(), doc.id);
// Vérifier si l'équipement est directement assigné
final hasEquipmentDirectly = event.assignedEquipment.any(
(eq) => eq.equipmentId == widget.equipment.id,
);
// Vérifier si l'équipement est dans une boîte assignée à l'événement
final hasEquipmentInAssignedContainer = event.assignedContainers.any(
(containerId) => containersWithEquipment.contains(containerId),
);
if (hasEquipmentDirectly || hasEquipmentInAssignedContainer) {
// Un événement est EN COURS dès que la préparation est complétée
// et jusqu'à ce que le retour soit complété
final isPrepared = event.preparationStatus == PreparationStatus.completed ||
event.preparationStatus == PreparationStatus.completedWithMissing;
final isReturned = event.returnStatus == ReturnStatus.completed ||
event.returnStatus == ReturnStatus.completedWithMissing;
final isInProgress = isPrepared && !isReturned;
if (isInProgress) {
events.add(event);
}
}
} catch (e) {
print('[EquipmentCurrentEventsSection] Error parsing event ${doc.id}: $e');
}
}
// Trier par date
events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
setState(() {
_events = events;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors du chargement: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
// Ne rien afficher si pas d'événements en cours
if (!_isLoading && _events.isEmpty) {
return const SizedBox.shrink();
}
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête
Row(
children: [
const Icon(Icons.play_circle, color: AppColors.rouge),
const SizedBox(width: 8),
Expanded(
child: Text(
'Événements en cours utilisant ce matériel',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
const Divider(height: 24),
// Liste des événements
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(32.0),
child: CircularProgressIndicator(),
),
)
else
Column(
children: _events.map((event) => _buildEventCard(event)).toList(),
),
],
),
),
);
}
Widget _buildEventCard(EventModel event) {
final dateFormat = DateFormat('dd/MM/yyyy HH:mm');
// Trouver la quantité utilisée pour les consommables/câbles
int? usedQuantity;
if (widget.equipment.hasQuantity) {
final assignedEquipment = event.assignedEquipment.firstWhere(
(eq) => eq.equipmentId == widget.equipment.id,
orElse: () => EventEquipment(equipmentId: ''),
);
if (assignedEquipment.equipmentId.isNotEmpty) {
usedQuantity = assignedEquipment.quantity;
}
}
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: const BorderSide(color: AppColors.rouge, width: 2),
),
child: InkWell(
onTap: () {
// Navigation vers les détails de l'événement si nécessaire
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre de l'événement
Row(
children: [
Expanded(
child: Text(
event.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.rouge,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'EN COURS',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 8),
// Dates
Row(
children: [
const Icon(Icons.calendar_today, size: 14, color: Colors.grey),
const SizedBox(width: 4),
Expanded(
child: Text(
'${dateFormat.format(event.startDateTime)}${dateFormat.format(event.endDateTime)}',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
),
),
),
],
),
// Quantité utilisée pour consommables/câbles
if (usedQuantity != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.inventory, size: 14, color: Colors.blue),
const SizedBox(width: 6),
Text(
'Quantité utilisée: $usedQuantity',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
],
),
),
],
],
),
),
),
);
}
}

View File

@@ -40,7 +40,7 @@ class EquipmentHeaderSection extends StatelessWidget {
radius: 30,
child: equipment.category.getIcon(
size: 32,
color: AppColors.rouge,
color: equipment.category.color,
),
),
const SizedBox(width: 16),

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/providers/equipment_provider.dart';
/// Widget qui affiche le badge de statut d'un équipement
/// Le statut est calculé de manière asynchrone basé sur les événements en cours
class EquipmentStatusBadge extends StatelessWidget {
final EquipmentModel equipment;
const EquipmentStatusBadge({
super.key,
required this.equipment,
});
@override
Widget build(BuildContext context) {
final provider = Provider.of<EquipmentProvider>(context, listen: false);
print('[EquipmentStatusBadge] Building badge for ${equipment.id}');
return FutureBuilder<EquipmentStatus>(
// On calcule le statut réel de manière asynchrone
future: provider.calculateRealStatus(equipment),
// En attendant, on affiche le statut stocké
initialData: equipment.status,
builder: (context, snapshot) {
// Utiliser le statut calculé s'il est disponible, sinon le statut stocké
final status = snapshot.data ?? equipment.status;
print('[EquipmentStatusBadge] ${equipment.id} - Status: ${status.label} (hasData: ${snapshot.hasData}, connectionState: ${snapshot.connectionState})');
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: status.color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: status.color),
),
child: Text(
status.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: status.color,
),
),
);
},
);
}
}