refactor: Remplacement de l'accès direct à Firestore par des Cloud Functions

Migration complète du backend pour utiliser des Cloud Functions comme couche API sécurisée, en remplacement des appels directs à Firestore depuis le client.

**Backend (Cloud Functions):**
- **Centralisation CORS :** Ajout d'un middleware `withCors` et d'une configuration `httpOptions` pour gérer uniformément les en-têtes CORS et les requêtes `OPTIONS` sur toutes les fonctions.
- **Nouvelles Fonctions de Lecture (GET) :**
    - `getEquipments`, `getContainers`, `getEvents`, `getUsers`, `getOptions`, `getEventTypes`, `getRoles`, `getMaintenances`, `getAlerts`.
    - Ces fonctions gèrent les permissions côté serveur, masquant les données sensibles (ex: prix des équipements) pour les utilisateurs non-autorisés.
    - `getEvents` retourne également une map des utilisateurs (`usersMap`) pour optimiser le chargement des données de la main d'œuvre.
- **Nouvelle Fonction de Recherche :**
    - `getContainersByEquipment` : Endpoint dédié pour trouver efficacement tous les containers qui contiennent un équipement spécifique.
- **Nouvelles Fonctions d'Écriture (CRUD) :**
    - Fonctions CRUD complètes pour `eventTypes` (`create`, `update`, `delete`), incluant la validation (unicité du nom, vérification des événements futurs avant suppression).
- **Mise à jour de Fonctions Existantes :**
    - Toutes les fonctions CRUD existantes (`create/update/deleteEquipment`, `create/update/deleteContainer`, etc.) sont wrappées avec le nouveau gestionnaire CORS.

**Frontend (Flutter):**
- **Introduction du `DataService` :** Nouveau service centralisant tous les appels aux Cloud Functions, servant d'intermédiaire entre l'UI/Providers et l'API.
- **Refactorisation des Providers :**
    - `EquipmentProvider`, `ContainerProvider`, `EventProvider`, `UsersProvider`, `MaintenanceProvider` et `AlertProvider` ont été refactorisés pour utiliser le `DataService` au lieu d'accéder directement à Firestore.
    - Les `Stream` Firestore sont remplacés par des chargements de données via des méthodes `Future` (`loadEquipments`, `loadEvents`, etc.).
- **Gestion des Relations Équipement-Container :**
    - Le modèle `EquipmentModel` ne stocke plus `parentBoxIds`.
    - La relation est maintenant gérée par le `ContainerModel` qui contient `equipmentIds`.
    - Le `ContainerEquipmentService` est introduit pour utiliser la nouvelle fonction `getContainersByEquipment`.
    - L'affichage des boîtes parentes (`EquipmentParentContainers`) et le formulaire d'équipement (`EquipmentFormPage`) ont été mis à jour pour refléter ce nouveau modèle de données, synchronisant les ajouts/suppressions d'équipements dans les containers.
- **Amélioration de l'UI :**
    - Nouveau widget `ParentBoxesSelector` pour une sélection améliorée et visuelle des boîtes parentes dans le formulaire d'équipement.
    - Refonte visuelle de `EquipmentParentContainers` pour une meilleure présentation.
This commit is contained in:
ElPoyo
2026-01-12 20:38:46 +01:00
parent 13a890606d
commit f38d75362c
46 changed files with 3367 additions and 1510 deletions

View File

@@ -1,8 +1,9 @@
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:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:intl/intl.dart';
enum EventFilter {
@@ -26,6 +27,7 @@ class EquipmentAssociatedEventsSection extends StatefulWidget {
class _EquipmentAssociatedEventsSectionState
extends State<EquipmentAssociatedEventsSection> {
final DataService _dataService = DataService(FirebaseFunctionsApiService());
EventFilter _selectedFilter = EventFilter.upcoming;
List<EventModel> _events = [];
bool _isLoading = true;
@@ -40,36 +42,32 @@ 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')
.get();
// Récupérer TOUS les événements via l'API
final result = await _dataService.getEvents();
final eventsData = result['events'] as List<dynamic>;
final events = <EventModel>[];
// Récupérer toutes les boîtes pour vérifier leur contenu
final containersSnapshot = await FirebaseFirestore.instance
.collection('containers')
.get();
// Récupérer toutes les boîtes pour vérifier leur contenu via l'API
final containersData = await _dataService.getContainers();
final containersWithEquipment = <String>[];
for (var containerDoc in containersSnapshot.docs) {
for (var containerData in containersData) {
try {
final data = containerDoc.data();
final equipmentIds = List<String>.from(data['equipmentIds'] ?? []);
final equipmentIds = List<String>.from(containerData['equipmentIds'] ?? []);
if (equipmentIds.contains(widget.equipment.id)) {
containersWithEquipment.add(containerDoc.id);
containersWithEquipment.add(containerData['id'] as String);
}
} catch (e) {
print('[EquipmentAssociatedEventsSection] Error parsing container ${containerDoc.id}: $e');
print('[EquipmentAssociatedEventsSection] Error parsing container ${containerData['id']}: $e');
}
}
// Filtrer manuellement les événements qui contiennent cet équipement
for (var doc in eventsSnapshot.docs) {
for (var eventData in eventsData) {
try {
final event = EventModel.fromMap(doc.data(), doc.id);
final event = EventModel.fromMap(eventData, eventData['id'] as String);
// Vérifier si l'équipement est directement assigné
final hasEquipmentDirectly = event.assignedEquipment.any(
@@ -85,7 +83,7 @@ class _EquipmentAssociatedEventsSectionState
events.add(event);
}
} catch (e) {
print('[EquipmentAssociatedEventsSection] Error parsing event ${doc.id}: $e');
print('[EquipmentAssociatedEventsSection] Error parsing event ${eventData['id']}: $e');
}
}

View File

@@ -1,8 +1,9 @@
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:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:intl/intl.dart';
/// Widget pour afficher les événements EN COURS utilisant cet équipement
@@ -21,6 +22,7 @@ class EquipmentCurrentEventsSection extends StatefulWidget {
class _EquipmentCurrentEventsSectionState
extends State<EquipmentCurrentEventsSection> {
final DataService _dataService = DataService(FirebaseFunctionsApiService());
List<EventModel> _events = [];
bool _isLoading = true;
@@ -34,36 +36,32 @@ class _EquipmentCurrentEventsSectionState
setState(() => _isLoading = true);
try {
// Récupérer TOUS les événements
final eventsSnapshot = await FirebaseFirestore.instance
.collection('events')
.get();
// Récupérer TOUS les événements via l'API
final result = await _dataService.getEvents();
final eventsData = result['events'] as List<dynamic>;
final events = <EventModel>[];
// Récupérer toutes les boîtes pour vérifier leur contenu
final containersSnapshot = await FirebaseFirestore.instance
.collection('containers')
.get();
// Récupérer toutes les boîtes pour vérifier leur contenu via l'API
final containersData = await _dataService.getContainers();
final containersWithEquipment = <String>[];
for (var containerDoc in containersSnapshot.docs) {
for (var containerData in containersData) {
try {
final data = containerDoc.data();
final equipmentIds = List<String>.from(data['equipmentIds'] ?? []);
final equipmentIds = List<String>.from(containerData['equipmentIds'] ?? []);
if (equipmentIds.contains(widget.equipment.id)) {
containersWithEquipment.add(containerDoc.id);
containersWithEquipment.add(containerData['id'] as String);
}
} catch (e) {
print('[EquipmentCurrentEventsSection] Error parsing container ${containerDoc.id}: $e');
print('[EquipmentCurrentEventsSection] Error parsing container ${containerData['id']}: $e');
}
}
// Filtrer les événements en cours
for (var doc in eventsSnapshot.docs) {
for (var eventData in eventsData) {
try {
final event = EventModel.fromMap(doc.data(), doc.id);
final event = EventModel.fromMap(eventData, eventData['id'] as String);
// Vérifier si l'équipement est directement assigné
final hasEquipmentDirectly = event.assignedEquipment.any(
@@ -91,7 +89,7 @@ class _EquipmentCurrentEventsSectionState
}
}
} catch (e) {
print('[EquipmentCurrentEventsSection] Error parsing event ${doc.id}: $e');
print('[EquipmentCurrentEventsSection] Error parsing event $eventData: $e');
}
}

View File

@@ -1,177 +1,240 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/services/container_equipment_service.dart';
import 'package:em2rp/views/container_detail_page.dart';
/// Widget pour afficher les containers parents d'un équipement
class EquipmentParentContainers extends StatefulWidget {
final List<String> parentBoxIds;
/// Widget pour afficher les boîtes contenant un équipement
/// Utilise le nouveau système : interroge Firestore via Cloud Function
class EquipmentParentContainers extends StatelessWidget {
final String equipmentId;
const EquipmentParentContainers({
super.key,
required this.parentBoxIds,
required this.equipmentId,
});
@override
State<EquipmentParentContainers> createState() => _EquipmentParentContainersState();
}
class _EquipmentParentContainersState extends State<EquipmentParentContainers> {
List<ContainerModel> _containers = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadContainers();
}
Future<void> _loadContainers() async {
if (widget.parentBoxIds.isEmpty) {
setState(() {
_isLoading = false;
});
return;
}
setState(() {
_isLoading = true;
});
try {
final containerProvider = context.read<ContainerProvider>();
final List<ContainerModel> containers = [];
for (final boxId in widget.parentBoxIds) {
final container = await containerProvider.getContainerById(boxId);
if (container != null) {
containers.add(container);
}
}
setState(() {
_containers = containers;
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
if (widget.parentBoxIds.isEmpty) {
return const SizedBox.shrink();
}
return FutureBuilder<List<ContainerModel>>(
future: containerEquipmentService.getContainersByEquipment(equipmentId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: CircularProgressIndicator(),
),
),
);
}
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, size: 20),
const SizedBox(width: 8),
const Text(
'Containers',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
if (snapshot.hasError) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(Icons.error_outline, color: Colors.red.shade700),
const SizedBox(width: 12),
Expanded(
child: Text(
'Erreur lors du chargement des boîtes',
style: TextStyle(color: Colors.red.shade700),
),
),
],
),
),
);
}
final containers = snapshot.data ?? [];
if (containers.isEmpty) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.grey.shade600),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Cet équipement n\'est dans aucune boîte',
style: TextStyle(color: Colors.grey),
),
),
],
),
),
);
}
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.inventory_2, color: AppColors.rouge, size: 20),
const SizedBox(width: 8),
Text(
'Boîtes contenant cet équipement (${containers.length})',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
...containers.map((container) => _buildContainerCard(context, container)),
],
),
const Divider(height: 24),
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
)
else if (_containers.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text(
'Cet équipement n\'est dans aucun container',
style: TextStyle(color: Colors.grey),
),
),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _containers.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final container = _containers[index];
return _buildContainerTile(container);
},
),
],
),
),
);
}
Widget _buildContainerTile(ContainerModel container) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(vertical: 8),
leading: Icon(
_getTypeIcon(container.type),
color: AppColors.rouge,
size: 32,
),
title: Text(
container.id,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(container.name),
const SizedBox(height: 4),
Text(
containerTypeLabel(container.type),
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.pushNamed(
context,
'/container_detail',
arguments: container,
);
},
);
}
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;
}
Widget _buildContainerCard(BuildContext context, ContainerModel container) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Material(
elevation: 1,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ContainerDetailPage(container: container),
),
);
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
// Icône du type de container
CircleAvatar(
backgroundColor: AppColors.rouge.withValues(alpha: 0.1),
radius: 24,
child: container.type.getIconForAvatar(
size: 24,
color: AppColors.rouge,
),
),
const SizedBox(width: 12),
// Informations du container
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
container.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
const SizedBox(height: 4),
Text(
container.type.label,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 6),
// Badges
Wrap(
spacing: 6,
runSpacing: 4,
children: [
_buildInfoChip(
icon: Icons.inventory,
label: '${container.itemCount} équip.',
color: Colors.blue,
),
if (container.weight != null)
_buildInfoChip(
icon: Icons.scale,
label: '${container.weight!.toStringAsFixed(1)} kg',
color: Colors.orange,
),
_buildInfoChip(
icon: Icons.tag,
label: container.id,
color: Colors.grey,
isCompact: true,
),
],
),
],
),
),
// Icône de navigation
Icon(Icons.chevron_right, color: Colors.grey.shade400),
],
),
),
),
),
);
}
Widget _buildInfoChip({
required IconData icon,
required String label,
required Color color,
bool isCompact = false,
}) {
return Container(
padding: EdgeInsets.symmetric(
horizontal: isCompact ? 6 : 8,
vertical: 3,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: color.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: isCompact ? 10 : 12,
color: color.withValues(alpha: 0.8),
),
const SizedBox(width: 3),
Text(
label,
style: TextStyle(
fontSize: isCompact ? 9 : 11,
fontWeight: FontWeight.w600,
color: color.withValues(alpha: 0.9),
),
),
],
),
);
}
}

View File

@@ -1,12 +1,12 @@
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/services/container_equipment_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 {
/// Utilise le nouveau système : interroge Firestore via Cloud Function
class EquipmentReferencingContainers extends StatelessWidget {
final String equipmentId;
const EquipmentReferencingContainers({
@@ -14,191 +14,161 @@ class EquipmentReferencingContainers extends StatefulWidget {
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 FutureBuilder<List<ContainerModel>>(
future: containerEquipmentService.getContainersByEquipment(equipmentId),
builder: (context, snapshot) {
// Ne rien afficher si vide et pas en chargement
if (snapshot.connectionState == ConnectionState.done &&
(!snapshot.hasData || snapshot.data!.isEmpty)) {
return const SizedBox.shrink();
}
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
if (snapshot.connectionState == ConnectionState.waiting) {
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Center(
child: CircularProgressIndicator(),
),
),
);
}
if (snapshot.hasError) {
return const SizedBox.shrink(); // Masquer en cas d'erreur
}
final containers = snapshot.data ?? [];
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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,
),
),
Row(
children: [
const Icon(Icons.inventory_2, color: AppColors.rouge),
const SizedBox(width: 8),
Expanded(
child: Text(
'Boîtes contenant cet équipement (${containers.length})',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
const Divider(height: 24),
_buildContainersGrid(context, containers),
],
),
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) {
Widget _buildContainersGrid(BuildContext context, List<ContainerModel> containers) {
return Column(
children: containers.map((container) {
return _buildContainerCard(context, container);
}).toList(),
);
}
Widget _buildContainerCard(BuildContext context, ContainerModel container) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.grey.shade200, width: 1),
),
child: InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ContainerDetailPage(container: container),
builder: (_) => ContainerDetailPage(container: container),
),
);
},
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Icône du type de container
container.type.getIcon(size: 28, color: AppColors.rouge),
const SizedBox(width: 10),
// Infos textuelles
CircleAvatar(
backgroundColor: AppColors.rouge.withValues(alpha: 0.1),
radius: 28,
child: container.type.getIconForAvatar(
size: 28,
color: AppColors.rouge,
),
),
const SizedBox(width: 16),
// Informations du container
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
container.id,
container.name,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
height: 1.0,
fontSize: 16,
),
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(height: 4),
Text(
container.type.label,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 6),
// Badges d'information
Wrap(
spacing: 8,
runSpacing: 4,
children: [
_buildInfoChip(
icon: Icons.inventory,
label: '${container.itemCount} équipement${container.itemCount > 1 ? 's' : ''}',
color: Colors.blue,
),
if (container.weight != null)
_buildInfoChip(
icon: Icons.scale,
label: '${container.weight!.toStringAsFixed(1)} kg',
color: Colors.orange,
),
_buildInfoChip(
icon: Icons.tag,
label: container.id,
color: Colors.grey,
isCompact: true,
),
],
),
],
),
),
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),
),
],
// Icône de navigation
Icon(
Icons.chevron_right,
color: AppColors.rouge,
size: 28,
),
],
),
@@ -207,82 +177,44 @@ class _EquipmentReferencingContainersState extends State<EquipmentReferencingCon
);
}
Widget _buildStatusBadge(String label, Color color) {
Widget _buildInfoChip({
required IconData icon,
required String label,
required Color color,
bool isCompact = false,
}) {
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),
padding: EdgeInsets.symmetric(
horizontal: isCompact ? 6 : 8,
vertical: isCompact ? 2 : 4,
),
child: Text(
label,
style: TextStyle(
fontSize: 9,
color: color,
fontWeight: FontWeight.bold,
height: 1.0,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withValues(alpha: 0.3),
width: 1,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: isCompact ? 12 : 14,
color: color.withValues(alpha: 0.8),
),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
fontSize: isCompact ? 10 : 12,
fontWeight: FontWeight.w600,
color: color.withValues(alpha: 0.9),
),
),
],
),
);
}
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,445 @@
import 'package:flutter/material.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/utils/colors.dart';
/// Widget pour sélectionner les boîtes parentes d'un équipement
class ParentBoxesSelector extends StatefulWidget {
final List<ContainerModel> availableBoxes;
final List<String> selectedBoxIds;
final Function(List<String>) onSelectionChanged;
const ParentBoxesSelector({
super.key,
required this.availableBoxes,
required this.selectedBoxIds,
required this.onSelectionChanged,
});
@override
State<ParentBoxesSelector> createState() => _ParentBoxesSelectorState();
}
class _ParentBoxesSelectorState extends State<ParentBoxesSelector> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void initState() {
super.initState();
print('[ParentBoxesSelector] initState');
print('[ParentBoxesSelector] Available boxes: ${widget.availableBoxes.length}');
print('[ParentBoxesSelector] Selected box IDs: ${widget.selectedBoxIds}');
// Log détaillé de chaque boîte
for (var box in widget.availableBoxes) {
print('[ParentBoxesSelector] Box - ID: ${box.id}, Name: ${box.name}');
}
}
@override
void didUpdateWidget(ParentBoxesSelector oldWidget) {
super.didUpdateWidget(oldWidget);
print('[ParentBoxesSelector] didUpdateWidget');
print('[ParentBoxesSelector] Old selected: ${oldWidget.selectedBoxIds}');
print('[ParentBoxesSelector] New selected: ${widget.selectedBoxIds}');
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<ContainerModel> get _filteredBoxes {
if (_searchQuery.isEmpty) {
return widget.availableBoxes;
}
final query = _searchQuery.toLowerCase();
return widget.availableBoxes.where((box) {
return box.name.toLowerCase().contains(query) ||
box.id.toLowerCase().contains(query) ||
box.type.label.toLowerCase().contains(query);
}).toList();
}
void _toggleSelection(String boxId) {
final newSelection = List<String>.from(widget.selectedBoxIds);
if (newSelection.contains(boxId)) {
newSelection.remove(boxId);
} else {
newSelection.add(boxId);
}
widget.onSelectionChanged(newSelection);
}
@override
Widget build(BuildContext context) {
if (widget.availableBoxes.isEmpty && widget.selectedBoxIds.isEmpty) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.grey.shade600),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Aucune boîte disponible',
style: TextStyle(color: Colors.grey),
),
),
],
),
),
);
}
final filteredBoxes = _filteredBoxes;
final selectedCount = widget.selectedBoxIds.length;
// Vérifier s'il y a des boîtes sélectionnées qui ne sont pas dans la liste
final missingBoxIds = widget.selectedBoxIds
.where((id) => !widget.availableBoxes.any((box) => box.id == id))
.toList();
return Card(
elevation: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête avec titre et compteur
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
const Icon(Icons.inventory_2, color: AppColors.rouge, size: 20),
const SizedBox(width: 8),
const Text(
'Boîtes parentes',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (selectedCount > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppColors.rouge.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.rouge.withValues(alpha: 0.3),
width: 1,
),
),
child: Text(
'$selectedCount sélectionné${selectedCount > 1 ? 's' : ''}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.rouge,
),
),
),
],
),
),
const Divider(height: 1),
// Message d'avertissement si des boîtes sélectionnées sont manquantes
if (missingBoxIds.isNotEmpty)
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade300),
),
child: Row(
children: [
Icon(Icons.warning_amber, color: Colors.orange.shade700),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Boîtes introuvables',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.orange.shade900,
),
),
const SizedBox(height: 4),
Text(
'Les boîtes suivantes sont sélectionnées mais n\'existent plus : ${missingBoxIds.join(", ")}',
style: TextStyle(
fontSize: 13,
color: Colors.orange.shade800,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
// Retirer les boîtes manquantes de la sélection
final newSelection = widget.selectedBoxIds
.where((id) => !missingBoxIds.contains(id))
.toList();
widget.onSelectionChanged(newSelection);
},
tooltip: 'Retirer ces boîtes',
),
],
),
),
// Barre de recherche
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
decoration: InputDecoration(
hintText: 'Rechercher par nom, ID ou type...',
prefixIcon: const Icon(Icons.search, color: AppColors.rouge),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
setState(() {
_searchController.clear();
_searchQuery = '';
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.rouge, width: 2),
),
filled: true,
fillColor: Colors.grey.shade50,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
),
// Message si aucun résultat
if (filteredBoxes.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Center(
child: Column(
children: [
Icon(Icons.search_off, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text(
'Aucune boîte trouvée',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
'Essayez une autre recherche',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade500,
),
),
],
),
),
)
else
// Liste des boîtes
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: filteredBoxes.length,
separatorBuilder: (context, index) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final box = filteredBoxes[index];
final isSelected = widget.selectedBoxIds.contains(box.id);
if (index == 0) {
print('[ParentBoxesSelector] Building item $index');
print('[ParentBoxesSelector] Box ID: ${box.id}');
print('[ParentBoxesSelector] Selected IDs: ${widget.selectedBoxIds}');
print('[ParentBoxesSelector] Is selected: $isSelected');
}
return _buildBoxCard(box, isSelected);
},
),
const SizedBox(height: 16),
],
),
);
}
Widget _buildBoxCard(ContainerModel box, bool isSelected) {
return Card(
elevation: isSelected ? 3 : 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: isSelected ? AppColors.rouge : Colors.grey.shade300,
width: isSelected ? 2 : 1,
),
),
child: InkWell(
onTap: () => _toggleSelection(box.id),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Checkbox
Checkbox(
value: isSelected,
onChanged: (value) => _toggleSelection(box.id),
activeColor: AppColors.rouge,
),
const SizedBox(width: 8),
// Icône du type de container
CircleAvatar(
backgroundColor: isSelected
? AppColors.rouge.withValues(alpha: 0.15)
: Colors.grey.shade200,
radius: 24,
child: box.type.getIconForAvatar(
size: 24,
color: isSelected ? AppColors.rouge : Colors.grey.shade700,
),
),
const SizedBox(width: 12),
// Informations de la boîte
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
box.name,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
color: isSelected ? AppColors.rouge : Colors.black87,
),
),
const SizedBox(height: 4),
Text(
box.type.label,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 6),
// Badges
Wrap(
spacing: 6,
runSpacing: 4,
children: [
_buildInfoChip(
icon: Icons.inventory,
label: '${box.itemCount} équip.',
color: Colors.blue,
),
if (box.weight != null)
_buildInfoChip(
icon: Icons.scale,
label: '${box.weight!.toStringAsFixed(1)} kg',
color: Colors.orange,
),
_buildInfoChip(
icon: Icons.tag,
label: box.id,
color: Colors.grey,
isCompact: true,
),
],
),
],
),
),
// Indicateur de sélection
if (isSelected)
const Icon(
Icons.check_circle,
color: AppColors.rouge,
size: 24,
),
],
),
),
),
);
}
Widget _buildInfoChip({
required IconData icon,
required String label,
required Color color,
bool isCompact = false,
}) {
return Container(
padding: EdgeInsets.symmetric(
horizontal: isCompact ? 6 : 8,
vertical: 3,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: color.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: isCompact ? 10 : 12,
color: color.withValues(alpha: 0.8),
),
const SizedBox(width: 3),
Text(
label,
style: TextStyle(
fontSize: isCompact ? 9 : 11,
fontWeight: FontWeight.w600,
color: color.withValues(alpha: 0.9),
),
),
],
),
);
}
}

View File

@@ -189,10 +189,13 @@ class _RestockDialogState extends State<RestockDialog> {
};
if (context.mounted) {
await context.read<EquipmentProvider>().updateEquipment(
widget.equipment.id,
updatedData,
);
final updatedEquipment = widget.equipment.copyWith(
availableQuantity: newAvailable,
totalQuantity: newTotal,
updatedAt: DateTime.now(),
);
await context.read<EquipmentProvider>().updateEquipment(updatedEquipment);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(