Refactor de la page de détails de l'équipement et ajouts de widgets communs

Refactor de la page `equipment_detail_page` en la décomposant en plusieurs widgets de section réutilisables pour une meilleure lisibilité et maintenance :
- `EquipmentHeaderSection` : En-tête avec titre et informations principales.
- `EquipmentMainInfoSection` : Informations sur la catégorie, la marque, le modèle et le statut.
- `EquipmentNotesSection` : Affichage des notes.
- `EquipmentDatesSection` : Gestion de l'affichage des dates (achat, maintenance, création, etc.).
- `EquipmentPriceSection` : Section dédiée aux prix.
- `EquipmentMaintenanceHistorySection` : Historique des maintenances.
- `EquipmentAssociatedEventsSection` : Placeholder pour les événements à venir.
- `EquipmentReferencingContainers` : Affiche les boites (containers) qui contiennent cet équipement.

Ajout de plusieurs widgets communs et utilitaires :
- Widgets UI : `SearchBarWidget`, `SelectionAppBar`, `CustomFilterChip`, `EmptyState`, `InfoChip`, `StatusBadge`, `QuantityDisplay`.
- Dialogues : `RestockDialog` pour les consommables et `DialogUtils` pour les confirmations génériques.

Autres modifications :
- Mise à jour de la terminologie "Container" en "Boite" dans l'interface utilisateur.
- Amélioration de la sélection d'équipements dans le formulaire des boites.
- Ajout d'instructions pour Copilot (`copilot-instructions.md`).
- Mise à jour de certaines icônes pour les types de boites.
This commit is contained in:
ElPoyo
2025-10-30 17:40:28 +01:00
parent df6d54a007
commit 822d4443f9
25 changed files with 1687 additions and 570 deletions

6
em2rp/.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,6 @@
CLEAN CODE très important: Toujours écrire du code propre, lisible et bien structuré. Utiliser des noms de variables et de fonctions explicites, éviter les répétitions inutiles et suivre les meilleures pratiques de codage.
Penser a créer des fonctions réutilisables pour éviter la duplication de code.
Verifier la présence de composant existants ou librairie existante avant de créer du code maison. Reutiliser le plus possible le code. Ne pas héister a analyser fréquemment la codebase existante.
Créer des fichiers séparés pour chaque composant, classe ou module afin de faciliter la maintenance et la réutilisation. Il faut eviter de dépasser 600 lignes par fichier.
Si quelque chose n'est pas clair, poser des questions pour clarifier les exigences avant de commencer à coder.
Ne pas générer de fichier résumant le code généré.

View File

@@ -83,13 +83,13 @@ extension ContainerTypeExtension on ContainerType {
case ContainerType.flightCase:
return Icons.work;
case ContainerType.pelicase:
return Icons.inventory_2;
return Icons.work_outline;
case ContainerType.bag:
return Icons.shopping_bag;
case ContainerType.openCrate:
return Icons.inventory;
return Icons.inventory_2;
case ContainerType.toolbox:
return Icons.build_circle;
return Icons.home_repair_service;
}
}

View File

@@ -183,7 +183,7 @@ class ContainerService {
'success': true,
'message': 'Équipement ajouté avec succès',
'warnings': otherContainers.isNotEmpty
? 'Attention : cet équipement est également dans les containers suivants : ${otherContainers.join(", ")}'
? 'Attention : cet équipement est également dans les boites suivants : ${otherContainers.join(", ")}'
: null,
};
} catch (e) {

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
/// Utilitaires pour afficher des dialogues de confirmation
class DialogUtils {
/// Affiche un dialogue de confirmation de suppression
static Future<bool> showDeleteConfirmation({
required BuildContext context,
required String title,
required String message,
String confirmButtonText = 'Supprimer',
String cancelButtonText = 'Annuler',
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(cancelButtonText),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: Text(confirmButtonText),
),
],
),
);
return result ?? false;
}
/// Affiche un dialogue de confirmation générique
static Future<bool> showConfirmation({
required BuildContext context,
required String title,
required String message,
String confirmButtonText = 'Confirmer',
String cancelButtonText = 'Annuler',
Color? confirmButtonColor,
}) async {
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(cancelButtonText),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: confirmButtonColor != null
? ElevatedButton.styleFrom(
backgroundColor: confirmButtonColor,
foregroundColor: Colors.white,
)
: null,
child: Text(confirmButtonText),
),
],
),
);
return result ?? false;
}
}

View File

@@ -68,7 +68,7 @@ class _ContainerDetailPageState extends State<ContainerDetailPage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Détails du Container'),
title: const Text('Détails de la boite'),
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
actions: [
@@ -131,11 +131,7 @@ class _ContainerDetailPageState extends State<ContainerDetailPage> {
children: [
Row(
children: [
Icon(
_getTypeIcon(_container.type),
size: 60,
color: AppColors.rouge,
),
_container.type.getIcon(size:60, color:AppColors.rouge),
const SizedBox(width: 20),
Expanded(
child: Column(
@@ -580,22 +576,6 @@ class _ContainerDetailPageState extends State<ContainerDetailPage> {
return '${totalWeight.toStringAsFixed(1)} kg';
}
IconData _getTypeIcon(ContainerType type) {
switch (type) {
case ContainerType.flightCase:
return Icons.work;
case ContainerType.pelicase:
return Icons.work_outline;
case ContainerType.bag:
return Icons.shopping_bag;
case ContainerType.openCrate:
return Icons.inventory_2;
case ContainerType.toolbox:
return Icons.handyman;
}
}
String _getStatusLabel(EquipmentStatus status) {
switch (status) {
case EquipmentStatus.available:

View File

@@ -90,7 +90,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? 'Modifier Container' : 'Nouveau Container'),
title: Text(_isEditing ? 'Modifier boite' : 'Nouvelle boite'),
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
@@ -636,6 +636,14 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
final TextEditingController _searchController = TextEditingController();
EquipmentCategory? _filterCategory;
String _searchQuery = '';
late Set<String> _tempSelectedIds;
@override
void initState() {
super.initState();
// Créer une copie temporaire des IDs sélectionnés
_tempSelectedIds = Set<String>.from(widget.selectedIds);
}
@override
void dispose() {
@@ -759,7 +767,7 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
const Icon(Icons.check_circle, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'${widget.selectedIds.length} équipement(s) sélectionné(s)',
'${_tempSelectedIds.length} équipement(s) sélectionné(s)',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
@@ -807,16 +815,16 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
itemCount: equipment.length,
itemBuilder: (context, index) {
final item = equipment[index];
final isSelected = widget.selectedIds.contains(item.id);
final isSelected = _tempSelectedIds.contains(item.id);
return CheckboxListTile(
value: isSelected,
onChanged: (selected) {
setState(() {
if (selected == true) {
widget.selectedIds.add(item.id);
_tempSelectedIds.add(item.id);
} else {
widget.selectedIds.remove(item.id);
_tempSelectedIds.remove(item.id);
}
});
},
@@ -857,12 +865,20 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
onPressed: () {
// Ne pas appliquer les modifications, juste fermer
Navigator.pop(context);
},
child: const Text('Annuler'),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: () => Navigator.pop(context),
onPressed: () {
// Appliquer les modifications à l'original
widget.selectedIds.clear();
widget.selectedIds.addAll(_tempSelectedIds);
Navigator.pop(context);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
),
@@ -920,5 +936,4 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
return Icons.category;
}
}
}
}

View File

@@ -10,7 +10,14 @@ import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/views/equipment_form_page.dart';
import 'package:em2rp/views/widgets/equipment/equipment_parent_containers.dart';
import 'package:intl/intl.dart';
import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart';
import 'package:em2rp/views/widgets/equipment/equipment_header_section.dart';
import 'package:em2rp/views/widgets/equipment/equipment_main_info_section.dart';
import 'package:em2rp/views/widgets/equipment/equipment_notes_section.dart';
import 'package:em2rp/views/widgets/equipment/equipment_associated_events_section.dart';
import 'package:em2rp/views/widgets/equipment/equipment_price_section.dart';
import 'package:em2rp/views/widgets/equipment/equipment_maintenance_history_section.dart';
import 'package:em2rp/views/widgets/equipment/equipment_dates_section.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:printing/printing.dart';
@@ -49,9 +56,11 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
}
@override
Widget build(BuildContext context) {
final isMobile = MediaQuery.of(context).size.width < 800;
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth >= 1200;
final userProvider = Provider.of<LocalUserProvider>(context);
final hasManagePermission = userProvider.hasPermission('manage_equipment');
@@ -79,539 +88,102 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
],
),
body: SingleChildScrollView(
padding: EdgeInsets.all(isMobile ? 16 : 24),
padding: EdgeInsets.all(screenWidth < 800 ? 16 : 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
// 1. Titre de la machine
EquipmentHeaderSection(equipment: widget.equipment),
const SizedBox(height: 24),
_buildMainInfoSection(),
// 2. Info principale
EquipmentMainInfoSection(equipment: widget.equipment),
const SizedBox(height: 24),
if (hasManagePermission) ...[
_buildPriceSection(),
const SizedBox(height: 24),
],
if (widget.equipment.category == EquipmentCategory.consumable ||
widget.equipment.category == EquipmentCategory.cable) ...[
_buildQuantitySection(),
// 3. Notes
if (widget.equipment.notes != null && widget.equipment.notes!.isNotEmpty) ...[
EquipmentNotesSection(notes: widget.equipment.notes!),
const SizedBox(height: 24),
],
// 4. Événements associés
const EquipmentAssociatedEventsSection(),
const SizedBox(height: 24),
// 5-7. Prix, Historique des maintenances, Dates en layout responsive
if (isDesktop)
_buildDesktopTwoColumnLayout(hasManagePermission)
else
_buildMobileLayout(hasManagePermission),
const SizedBox(height: 24),
// Containers parents (si applicable)
if (widget.equipment.parentBoxIds.isNotEmpty) ...[
EquipmentParentContainers(
parentBoxIds: widget.equipment.parentBoxIds,
),
const SizedBox(height: 24),
],
_buildDatesSection(),
const SizedBox(height: 24),
if (widget.equipment.notes != null && widget.equipment.notes!.isNotEmpty) ...[
_buildNotesSection(),
// Containers associés
EquipmentReferencingContainers(
equipmentId: widget.equipment.id,
),
],
),
),
);
}
/// Layout 2 colonnes pour desktop
Widget _buildDesktopTwoColumnLayout(bool hasManagePermission) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Colonne gauche
Expanded(
child: Column(
children: [
// Prix
EquipmentPriceSection(equipment: widget.equipment),
const SizedBox(height: 24),
],
_buildMaintenanceHistorySection(hasManagePermission),
const SizedBox(height: 24),
_buildAssociatedEventsSection(),
],
),
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppColors.rouge, AppColors.rouge.withValues(alpha: 0.8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppColors.rouge.withValues(alpha: 0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: Colors.white,
radius: 30,
child: widget.equipment.category.getIcon(
size: 32,
color: AppColors.rouge,
),
// Historique des maintenances
EquipmentMaintenanceHistorySection(
maintenances: _maintenances,
isLoading: _isLoadingMaintenances,
hasManagePermission: hasManagePermission,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.equipment.id,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim().isNotEmpty
? '${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim()
: 'Marque/Modèle non défini',
style: const TextStyle(
fontSize: 16,
color: Colors.white70,
),
),
],
),
),
if (widget.equipment.category != EquipmentCategory.consumable &&
widget.equipment.category != EquipmentCategory.cable)
_buildStatusBadge(),
],
),
],
),
);
}
Widget _buildStatusBadge() {
final status = widget.equipment.status;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: status.color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
status.label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: status.color,
),
),
],
),
);
}
Widget _buildMainInfoSection() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.info_outline, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'Informations principales',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(height: 24),
_buildInfoRow('Catégorie', widget.equipment.category.label),
if (widget.equipment.brand != null && widget.equipment.brand!.isNotEmpty)
_buildInfoRow('Marque', widget.equipment.brand!),
if (widget.equipment.model != null && widget.equipment.model!.isNotEmpty)
_buildInfoRow('Modèle', widget.equipment.model!),
if (widget.equipment.category != EquipmentCategory.consumable &&
widget.equipment.category != EquipmentCategory.cable)
_buildInfoRow('Statut', widget.equipment.status.label),
],
),
),
);
}
Widget _buildPriceSection() {
final hasPrices = widget.equipment.purchasePrice != null || widget.equipment.rentalPrice != null;
if (!hasPrices) return const SizedBox.shrink();
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.euro, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'Prix',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(height: 24),
if (widget.equipment.purchasePrice != null)
_buildInfoRow(
'Prix d\'achat',
'${widget.equipment.purchasePrice!.toStringAsFixed(2)}',
),
if (widget.equipment.rentalPrice != null)
_buildInfoRow(
'Prix de location',
'${widget.equipment.rentalPrice!.toStringAsFixed(2)} €/jour',
),
],
const SizedBox(width: 24),
// Colonne droite
Expanded(
child: EquipmentDatesSection(equipment: widget.equipment),
),
),
],
);
}
Widget _buildQuantitySection() {
final availableQty = widget.equipment.availableQuantity ?? 0;
final totalQty = widget.equipment.totalQuantity ?? 0;
final criticalThreshold = widget.equipment.criticalThreshold ?? 0;
final isCritical = criticalThreshold > 0 && availableQty <= criticalThreshold;
return Card(
elevation: 2,
color: isCritical ? Colors.red.shade50 : null,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
isCritical ? Icons.warning : Icons.inventory,
color: isCritical ? Colors.red : AppColors.rouge,
),
const SizedBox(width: 8),
Text(
'Quantités',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: isCritical ? Colors.red : null,
),
),
if (isCritical) ...[
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'STOCK CRITIQUE',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
],
],
),
const Divider(height: 24),
_buildInfoRow(
'Quantité disponible',
availableQty.toString(),
valueColor: isCritical ? Colors.red : null,
valueWeight: isCritical ? FontWeight.bold : null,
),
_buildInfoRow('Quantité totale', totalQty.toString()),
if (criticalThreshold > 0)
_buildInfoRow('Seuil critique', criticalThreshold.toString()),
],
/// Layout simple colonne pour mobile/tablette
Widget _buildMobileLayout(bool hasManagePermission) {
return Column(
children: [
EquipmentPriceSection(equipment: widget.equipment),
const SizedBox(height: 24),
EquipmentMaintenanceHistorySection(
maintenances: _maintenances,
isLoading: _isLoadingMaintenances,
hasManagePermission: hasManagePermission,
),
),
const SizedBox(height: 24),
EquipmentDatesSection(equipment: widget.equipment),
],
);
}
Widget _buildDatesSection() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.calendar_today, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'Dates',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(height: 24),
if (widget.equipment.purchaseDate != null)
_buildInfoRow(
'Date d\'achat',
DateFormat('dd/MM/yyyy').format(widget.equipment.purchaseDate!),
),
if (widget.equipment.lastMaintenanceDate != null)
_buildInfoRow(
'Dernière maintenance',
DateFormat('dd/MM/yyyy').format(widget.equipment.lastMaintenanceDate!),
),
if (widget.equipment.nextMaintenanceDate != null)
_buildInfoRow(
'Prochaine maintenance',
DateFormat('dd/MM/yyyy').format(widget.equipment.nextMaintenanceDate!),
valueColor: widget.equipment.nextMaintenanceDate!.isBefore(DateTime.now())
? Colors.red
: null,
),
_buildInfoRow(
'Créé le',
DateFormat('dd/MM/yyyy à HH:mm').format(widget.equipment.createdAt),
),
_buildInfoRow(
'Modifié le',
DateFormat('dd/MM/yyyy à HH:mm').format(widget.equipment.updatedAt),
),
],
),
),
);
}
Widget _buildNotesSection() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.notes, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'Notes',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(height: 24),
Text(
widget.equipment.notes!,
style: const TextStyle(fontSize: 14),
),
],
),
),
);
}
Widget _buildMaintenanceHistorySection(bool hasManagePermission) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.build, color: AppColors.rouge),
const SizedBox(width: 8),
Expanded(
child: Text(
'Historique des maintenances',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
const Divider(height: 24),
if (_isLoadingMaintenances)
const Center(child: CircularProgressIndicator())
else if (_maintenances.isEmpty)
const Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: Text(
'Aucune maintenance enregistrée',
style: TextStyle(color: Colors.grey),
),
),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _maintenances.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final maintenance = _maintenances[index];
return _buildMaintenanceItem(maintenance, hasManagePermission);
},
),
],
),
),
);
}
Widget _buildMaintenanceItem(MaintenanceModel maintenance, bool showCost) {
final isCompleted = maintenance.completedDate != null;
final typeInfo = _getMaintenanceTypeInfo(maintenance.type);
return ListTile(
contentPadding: const EdgeInsets.symmetric(vertical: 8),
leading: CircleAvatar(
backgroundColor: isCompleted ? Colors.green.withValues(alpha: 0.2) : Colors.orange.withValues(alpha: 0.2),
child: Icon(
isCompleted ? Icons.check_circle : Icons.schedule,
color: isCompleted ? Colors.green : Colors.orange,
),
),
title: Text(
maintenance.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Row(
children: [
Icon(typeInfo.$2, size: 16, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(typeInfo.$1, style: TextStyle(color: Colors.grey[600], fontSize: 12)),
],
),
const SizedBox(height: 4),
Text(
isCompleted
? 'Effectuée le ${DateFormat('dd/MM/yyyy').format(maintenance.completedDate!)}'
: 'Planifiée le ${DateFormat('dd/MM/yyyy').format(maintenance.scheduledDate)}',
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
if (showCost && maintenance.cost != null) ...[
const SizedBox(height: 4),
Text(
'Coût: ${maintenance.cost!.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
),
],
],
),
);
}
Widget _buildAssociatedEventsSection() {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.event, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'Événements associés',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(height: 24),
const Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: Text(
'Fonctionnalité à implémenter',
style: TextStyle(color: Colors.grey, fontStyle: FontStyle.italic),
),
),
),
],
),
),
);
}
Widget _buildInfoRow(
String label,
String value, {
Color? valueColor,
FontWeight? valueWeight,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 180,
child: Text(
label,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Colors.grey[700],
),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
color: valueColor,
fontWeight: valueWeight ?? FontWeight.w600,
),
),
),
],
),
);
}
void _showQRCode() {
showDialog(
context: context,
@@ -805,16 +377,4 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
),
);
}
(String, IconData) _getMaintenanceTypeInfo(MaintenanceType type) {
switch (type) {
case MaintenanceType.preventive:
return ('Préventive', Icons.schedule);
case MaintenanceType.corrective:
return ('Corrective', Icons.build);
case MaintenanceType.inspection:
return ('Inspection', Icons.search);
}
}
}
}

View File

@@ -169,7 +169,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
if (value != null && value.isNotEmpty) {
// Empêcher les ID commençant par BOX_ (réservé aux containers)
if (value.toUpperCase().startsWith('BOX_')) {
return 'Les ID commençant par BOX_ sont réservés aux containers';
return 'Les ID commençant par BOX_ sont réservés aux boites';
}
}
return null;

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:em2rp/utils/colors.dart';
/// Widget réutilisable pour créer des chips de filtre avec icône
class CustomFilterChip extends StatelessWidget {
final String label;
final Widget? icon;
final bool isSelected;
final VoidCallback onSelected;
final Color selectedColor;
final Color unselectedTextColor;
const CustomFilterChip({
super.key,
required this.label,
this.icon,
required this.isSelected,
required this.onSelected,
this.selectedColor = AppColors.rouge,
this.unselectedTextColor = AppColors.rouge,
});
@override
Widget build(BuildContext context) {
final color = isSelected ? Colors.white : unselectedTextColor;
return ChoiceChip(
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
icon!,
const SizedBox(width: 8),
],
Text(label),
],
),
selected: isSelected,
onSelected: (selected) {
if (selected) {
onSelected();
}
},
selectedColor: selectedColor,
labelStyle: TextStyle(
color: color,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
/// Widget réutilisable pour afficher un état vide (aucun élément)
class EmptyState extends StatelessWidget {
final IconData icon;
final String title;
final String? subtitle;
final double iconSize;
const EmptyState({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.iconSize = 64,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: iconSize, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
title,
style: TextStyle(fontSize: 18, color: Colors.grey[600]),
),
if (subtitle != null) ...[
const SizedBox(height: 8),
Text(
subtitle!,
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
],
],
),
);
}
}

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
/// Widget réutilisable pour afficher une puce d'information avec icône et texte
class InfoChip extends StatelessWidget {
final String label;
final Widget icon;
final Color? backgroundColor;
final Color? textColor;
final double fontSize;
final EdgeInsets padding;
const InfoChip({
super.key,
required this.label,
required this.icon,
this.backgroundColor,
this.textColor,
this.fontSize = 12,
this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
});
@override
Widget build(BuildContext context) {
return Container(
padding: padding,
decoration: BoxDecoration(
color: backgroundColor ?? Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
icon,
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: fontSize,
color: textColor ?? Colors.grey.shade700,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:em2rp/utils/colors.dart';
/// Widget réutilisable pour une barre de recherche
class SearchBarWidget extends StatelessWidget {
final TextEditingController controller;
final String hintText;
final ValueChanged<String> onChanged;
final VoidCallback? onClear;
final EdgeInsets padding;
final bool withShadow;
const SearchBarWidget({
super.key,
required this.controller,
required this.hintText,
required this.onChanged,
this.onClear,
this.padding = const EdgeInsets.all(16),
this.withShadow = true,
});
@override
Widget build(BuildContext context) {
return Container(
padding: padding,
decoration: withShadow
? BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
)
: null,
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText,
prefixIcon: const Icon(Icons.search, color: AppColors.rouge),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
controller.clear();
if (onClear != null) {
onClear!();
} else {
onChanged('');
}
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: onChanged,
),
);
}
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:em2rp/utils/colors.dart';
/// Widget réutilisable pour un AppBar en mode sélection
class SelectionAppBar extends StatelessWidget implements PreferredSizeWidget {
final int selectedCount;
final VoidCallback onClose;
final VoidCallback? onDelete;
final VoidCallback? onGenerateQR;
final List<Widget>? additionalActions;
const SelectionAppBar({
super.key,
required this.selectedCount,
required this.onClose,
this.onDelete,
this.onGenerateQR,
this.additionalActions,
});
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override
Widget build(BuildContext context) {
final hasSelection = selectedCount > 0;
return AppBar(
backgroundColor: AppColors.rouge,
leading: IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: onClose,
),
title: Text(
'$selectedCount sélectionné(s)',
style: const TextStyle(color: Colors.white),
),
actions: [
if (hasSelection) ...[
if (onGenerateQR != null)
IconButton(
icon: const Icon(Icons.qr_code, color: Colors.white),
tooltip: 'Générer QR Codes',
onPressed: onGenerateQR,
),
if (onDelete != null)
IconButton(
icon: const Icon(Icons.delete, color: Colors.white),
tooltip: 'Supprimer',
onPressed: onDelete,
),
if (additionalActions != null) ...additionalActions!,
],
],
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/equipment_model.dart';
/// Widget réutilisable pour afficher un badge de statut d'équipement ou container
class StatusBadge extends StatelessWidget {
final EquipmentStatus status;
final double fontSize;
final EdgeInsets padding;
const StatusBadge({
super.key,
required this.status,
this.fontSize = 12,
this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
});
@override
Widget build(BuildContext context) {
return Container(
padding: padding,
decoration: BoxDecoration(
color: status.color.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: status.color),
),
child: Text(
status.label,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
color: status.color,
),
),
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:em2rp/utils/colors.dart';
/// Widget pour afficher les événements associés
class EquipmentAssociatedEventsSection extends StatelessWidget {
const EquipmentAssociatedEventsSection({super.key});
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.event, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'Événements associés',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(height: 24),
const Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: Text(
'Fonctionnalité à implémenter',
style: TextStyle(color: Colors.grey, fontStyle: FontStyle.italic),
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/utils/colors.dart';
/// Widget pour afficher les dates
class EquipmentDatesSection extends StatelessWidget {
final EquipmentModel equipment;
const EquipmentDatesSection({
super.key,
required this.equipment,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.calendar_today, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'Dates',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(height: 24),
if (equipment.purchaseDate != null)
_buildInfoRow(
'Date d\'achat',
DateFormat('dd/MM/yyyy').format(equipment.purchaseDate!),
),
if (equipment.lastMaintenanceDate != null)
_buildInfoRow(
'Dernière maintenance',
DateFormat('dd/MM/yyyy').format(equipment.lastMaintenanceDate!),
),
if (equipment.nextMaintenanceDate != null)
_buildInfoRow(
'Prochaine maintenance',
DateFormat('dd/MM/yyyy').format(equipment.nextMaintenanceDate!),
valueColor: equipment.nextMaintenanceDate!.isBefore(DateTime.now())
? Colors.red
: null,
),
_buildInfoRow(
'Créé le',
DateFormat('dd/MM/yyyy à HH:mm').format(equipment.createdAt),
),
_buildInfoRow(
'Modifié le',
DateFormat('dd/MM/yyyy à HH:mm').format(equipment.updatedAt),
),
],
),
),
);
}
Widget _buildInfoRow(
String label,
String value, {
Color? valueColor,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 180,
child: Text(
label,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Colors.grey[700],
),
),
),
Expanded(
child: Text(
value,
style: TextStyle(
color: valueColor,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/utils/colors.dart';
/// Widget pour afficher l'en-tête (titre et infos principales)
class EquipmentHeaderSection extends StatelessWidget {
final EquipmentModel equipment;
const EquipmentHeaderSection({
super.key,
required this.equipment,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [AppColors.rouge, AppColors.rouge.withValues(alpha: 0.8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppColors.rouge.withValues(alpha: 0.3),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
backgroundColor: Colors.white,
radius: 30,
child: equipment.category.getIcon(
size: 32,
color: AppColors.rouge,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
equipment.id,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 4),
Text(
'${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim().isNotEmpty
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()
: 'Marque/Modèle non défini',
style: const TextStyle(
fontSize: 16,
color: Colors.white70,
),
),
],
),
),
if (equipment.category != EquipmentCategory.consumable &&
equipment.category != EquipmentCategory.cable)
_buildStatusBadge(),
],
),
],
),
);
}
Widget _buildStatusBadge() {
final status = equipment.status;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: status.color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
status.label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: status.color,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/utils/colors.dart';
/// Widget pour afficher les informations principales
class EquipmentMainInfoSection extends StatelessWidget {
final EquipmentModel equipment;
const EquipmentMainInfoSection({
super.key,
required this.equipment,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.info_outline, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'Informations principales',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(height: 24),
_buildInfoRow('Catégorie', equipment.category.label),
if (equipment.brand != null && equipment.brand!.isNotEmpty)
_buildInfoRow('Marque', equipment.brand!),
if (equipment.model != null && equipment.model!.isNotEmpty)
_buildInfoRow('Modèle', equipment.model!),
if (equipment.category != EquipmentCategory.consumable &&
equipment.category != EquipmentCategory.cable)
_buildInfoRow('Statut', equipment.status.label),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Colors.grey[700],
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/maintenance_model.dart';
import 'package:intl/intl.dart';
import 'package:em2rp/utils/colors.dart';
/// Widget pour afficher l'historique des maintenances
class EquipmentMaintenanceHistorySection extends StatelessWidget {
final List<MaintenanceModel> maintenances;
final bool isLoading;
final bool hasManagePermission;
const EquipmentMaintenanceHistorySection({
super.key,
required this.maintenances,
required this.isLoading,
required this.hasManagePermission,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.build, color: AppColors.rouge),
const SizedBox(width: 8),
Expanded(
child: Text(
'Historique des maintenances',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
const Divider(height: 24),
if (isLoading)
const Center(child: CircularProgressIndicator())
else if (maintenances.isEmpty)
const Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: Text(
'Aucune maintenance enregistrée',
style: TextStyle(color: Colors.grey),
),
),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: maintenances.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
return _buildMaintenanceItem(maintenances[index], hasManagePermission);
},
),
],
),
),
);
}
Widget _buildMaintenanceItem(MaintenanceModel maintenance, bool showCost) {
final isCompleted = maintenance.completedDate != null;
final typeInfo = _getMaintenanceTypeInfo(maintenance.type);
return ListTile(
contentPadding: const EdgeInsets.symmetric(vertical: 8),
leading: CircleAvatar(
backgroundColor: isCompleted ? Colors.green.withValues(alpha: 0.2) : Colors.orange.withValues(alpha: 0.2),
child: Icon(
isCompleted ? Icons.check_circle : Icons.schedule,
color: isCompleted ? Colors.green : Colors.orange,
),
),
title: Text(
maintenance.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Row(
children: [
Icon(typeInfo.$2, size: 16, color: Colors.grey[600]),
const SizedBox(width: 4),
Text(typeInfo.$1, style: TextStyle(color: Colors.grey[600], fontSize: 12)),
],
),
const SizedBox(height: 4),
Text(
isCompleted
? 'Effectuée le ${DateFormat('dd/MM/yyyy').format(maintenance.completedDate!)}'
: 'Planifiée le ${DateFormat('dd/MM/yyyy').format(maintenance.scheduledDate)}',
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
),
if (showCost && maintenance.cost != null) ...[
const SizedBox(height: 4),
Text(
'Coût: ${maintenance.cost!.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
),
],
],
),
);
}
(String, IconData) _getMaintenanceTypeInfo(MaintenanceType type) {
switch (type) {
case MaintenanceType.preventive:
return ('Préventive', Icons.schedule);
case MaintenanceType.corrective:
return ('Corrective', Icons.build);
case MaintenanceType.inspection:
return ('Inspection', Icons.search);
}
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:em2rp/utils/colors.dart';
/// Widget pour afficher les notes
class EquipmentNotesSection extends StatelessWidget {
final String notes;
const EquipmentNotesSection({
super.key,
required this.notes,
});
@override
Widget build(BuildContext context) {
if (notes.isEmpty) {
return const SizedBox.shrink();
}
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.notes, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'Notes',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(height: 24),
Text(
notes,
style: const TextStyle(fontSize: 14),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/utils/colors.dart';
/// Widget pour afficher les prix
class EquipmentPriceSection extends StatelessWidget {
final EquipmentModel equipment;
const EquipmentPriceSection({
super.key,
required this.equipment,
});
@override
Widget build(BuildContext context) {
final hasPrices = equipment.purchasePrice != null || equipment.rentalPrice != null;
if (!hasPrices) return const SizedBox.shrink();
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.euro, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'Prix',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(height: 24),
if (equipment.purchasePrice != null)
_buildInfoRow(
'Prix d\'achat',
'${equipment.purchasePrice!.toStringAsFixed(2)}',
),
if (equipment.rentalPrice != null)
_buildInfoRow(
'Prix de location',
'${equipment.rentalPrice!.toStringAsFixed(2)} €/jour',
),
],
),
),
);
}
Widget _buildInfoRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Colors.grey[700],
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,288 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/services/container_service.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/views/container_detail_page.dart';
/// Widget pour afficher les containers qui référencent un équipement
class EquipmentReferencingContainers extends StatefulWidget {
final String equipmentId;
const EquipmentReferencingContainers({
super.key,
required this.equipmentId,
});
@override
State<EquipmentReferencingContainers> createState() => _EquipmentReferencingContainersState();
}
class _EquipmentReferencingContainersState extends State<EquipmentReferencingContainers> {
final ContainerService _containerService = ContainerService();
List<ContainerModel> _referencingContainers = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadReferencingContainers();
}
Future<void> _loadReferencingContainers() async {
try {
final containers = await _containerService.findContainersWithEquipment(widget.equipmentId);
setState(() {
_referencingContainers = containers;
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
if (_referencingContainers.isEmpty && !_isLoading) {
return const SizedBox.shrink();
}
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.inventory_2, color: AppColors.rouge),
const SizedBox(width: 8),
Expanded(
child: Text(
'Containers contenant cet équipement',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
const Divider(height: 24),
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
)
else if (_referencingContainers.isEmpty)
const Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: Text(
'Cet équipement n\'est dans aucun container',
style: TextStyle(color: Colors.grey),
),
),
)
else
_buildContainersGrid(),
],
),
),
);
}
Widget _buildContainersGrid() {
final screenWidth = MediaQuery.of(context).size.width;
final isMobile = screenWidth < 800;
final isTablet = screenWidth < 1200;
final crossAxisCount = isMobile ? 1 : (isTablet ? 2 : 3);
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 12,
mainAxisSpacing: 8,
childAspectRatio: 7.5,
),
itemCount: _referencingContainers.length,
itemBuilder: (context, index) {
final container = _referencingContainers[index];
return _buildContainerCard(container);
},
);
}
Widget _buildContainerCard(ContainerModel container) {
return Card(
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ContainerDetailPage(container: container),
),
);
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
child: Row(
children: [
// Icône du type de container
container.type.getIcon(size: 28, color: AppColors.rouge),
const SizedBox(width: 10),
// Infos textuelles
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
container.id,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
height: 1.0,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
if (container.notes != null && container.notes!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
container.notes!,
style: TextStyle(
fontSize: 10,
color: Colors.grey[600],
height: 1.0,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
)
else
Text(
container.name,
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
height: 1.0,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
],
),
),
const SizedBox(width: 8),
// Badges compacts
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_buildStatusBadge(_getStatusLabel(container.status), _getStatusColor(container.status)),
if (container.itemCount > 0)
Padding(
padding: const EdgeInsets.only(top: 2),
child: _buildCountBadge(container.itemCount),
),
],
),
],
),
),
),
);
}
Widget _buildStatusBadge(String label, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withValues(alpha: 0.4), width: 0.5),
),
child: Text(
label,
style: TextStyle(
fontSize: 9,
color: color,
fontWeight: FontWeight.bold,
height: 1.0,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
);
}
Widget _buildCountBadge(int count) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green.withValues(alpha: 0.4), width: 0.5),
),
child: Text(
'$count article${count > 1 ? 's' : ''}',
style: const TextStyle(
fontSize: 9,
color: Colors.green,
fontWeight: FontWeight.bold,
height: 1.0,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
);
}
String _getStatusLabel(EquipmentStatus status) {
switch (status) {
case EquipmentStatus.available:
return 'Disponible';
case EquipmentStatus.inUse:
return 'En prestation';
case EquipmentStatus.rented:
return 'Loué';
case EquipmentStatus.lost:
return 'Perdu';
case EquipmentStatus.outOfService:
return 'HS';
case EquipmentStatus.maintenance:
return 'En maintenance';
}
}
Color _getStatusColor(EquipmentStatus status) {
switch (status) {
case EquipmentStatus.available:
return Colors.green;
case EquipmentStatus.inUse:
return Colors.blue;
case EquipmentStatus.rented:
return Colors.orange;
case EquipmentStatus.lost:
return Colors.red;
case EquipmentStatus.outOfService:
return Colors.red;
case EquipmentStatus.maintenance:
return Colors.yellow;
}
}
}

View File

@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/equipment_model.dart';
/// Widget pour afficher la quantité disponible/totale d'un équipement consommable
class QuantityDisplay extends StatelessWidget {
final EquipmentModel equipment;
const QuantityDisplay({
super.key,
required this.equipment,
});
@override
Widget build(BuildContext context) {
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,
),
),
),
],
],
),
);
}
}

View File

@@ -0,0 +1,223 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/utils/colors.dart';
/// Dialogue pour le restock d'un équipement consommable
class RestockDialog extends StatefulWidget {
final EquipmentModel equipment;
const RestockDialog({
super.key,
required this.equipment,
});
@override
State<RestockDialog> createState() => _RestockDialogState();
/// Méthode statique pour afficher le dialogue
static Future<void> show(BuildContext context, EquipmentModel equipment) {
return showDialog(
context: context,
builder: (context) => RestockDialog(equipment: equipment),
);
}
}
class _RestockDialogState extends State<RestockDialog> {
final TextEditingController _quantityController = TextEditingController();
bool _addToTotal = false;
@override
void dispose() {
_quantityController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(
children: [
const Icon(Icons.add_shopping_cart, color: AppColors.rouge),
const SizedBox(width: 12),
Expanded(
child: Text('Restock - ${widget.equipment.name}'),
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Quantités actuelles
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(
'${widget.equipment.availableQuantity ?? 0}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Total:'),
Text(
'${widget.equipment.totalQuantity ?? 0}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
],
),
),
const SizedBox(height: 20),
// Champ de saisie
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),
// Checkbox pour ajouter au total
StatefulBuilder(
builder: (context, setState) {
return 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;
});
// Update parent state as well
this.setState(() {
_addToTotal = value ?? false;
});
},
);
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => _handleRestock(context),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
child: const Text('Valider'),
),
],
);
}
Future<void> _handleRestock(BuildContext context) async {
final quantityText = _quantityController.text.trim();
if (quantityText.isEmpty) {
_showError(context, 'Veuillez entrer une quantité');
return;
}
final quantity = int.tryParse(quantityText);
if (quantity == null) {
_showError(context, 'Quantité invalide');
return;
}
Navigator.pop(context);
try {
final currentAvailable = widget.equipment.availableQuantity ?? 0;
final currentTotal = widget.equipment.totalQuantity ?? 0;
final newAvailable = currentAvailable + quantity;
final newTotal = _addToTotal ? currentTotal + quantity : currentTotal;
if (newAvailable < 0) {
_showError(context, 'La quantité disponible ne peut pas être négative');
return;
}
if (newTotal < 0) {
_showError(context, 'La quantité totale ne peut pas être négative');
return;
}
final updatedData = {
'availableQuantity': newAvailable,
'totalQuantity': newTotal,
'updatedAt': DateTime.now().toIso8601String(),
};
if (context.mounted) {
await context.read<EquipmentProvider>().updateEquipment(
widget.equipment.id,
updatedData,
);
if (context.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 (context.mounted) {
_showError(context, 'Erreur: $e');
}
}
}
void _showError(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
}

View File

@@ -112,19 +112,6 @@ class MainDrawer extends StatelessWidget {
},
),
),
PermissionGate(
requiredPermissions: const ['view_equipment'],
child: ListTile(
leading: const Icon(Icons.inventory_2),
title: const Text('Containers'),
selected: currentPage == '/container_management',
selectedColor: AppColors.rouge,
onTap: () {
Navigator.pop(context);
Navigator.pushNamed(context, '/container_management');
},
),
),
ExpansionTileTheme(
data: const ExpansionTileThemeData(
iconColor: AppColors.noir,