diff --git a/em2rp/assets/icons/flight-case.png b/em2rp/assets/icons/flight-case.png new file mode 100644 index 0000000..c90651d Binary files /dev/null and b/em2rp/assets/icons/flight-case.png differ diff --git a/em2rp/assets/logos/LowQRectangleLogoBlack.png b/em2rp/assets/logos/LowQRectangleLogoBlack.png new file mode 100644 index 0000000..10879db Binary files /dev/null and b/em2rp/assets/logos/LowQRectangleLogoBlack.png differ diff --git a/em2rp/assets/logos/RectangleLogoBlack.png b/em2rp/assets/logos/RectangleLogoBlack.png new file mode 100644 index 0000000..419ef7a Binary files /dev/null and b/em2rp/assets/logos/RectangleLogoBlack.png differ diff --git a/em2rp/assets/logos/RectangleLogoWhite.png b/em2rp/assets/logos/RectangleLogoWhite.png new file mode 100644 index 0000000..41c7956 Binary files /dev/null and b/em2rp/assets/logos/RectangleLogoWhite.png differ diff --git a/em2rp/lib/main.dart b/em2rp/lib/main.dart index 363bc19..b638a99 100644 --- a/em2rp/lib/main.dart +++ b/em2rp/lib/main.dart @@ -1,12 +1,17 @@ import 'package:em2rp/providers/users_provider.dart'; import 'package:em2rp/providers/event_provider.dart'; import 'package:em2rp/providers/equipment_provider.dart'; +import 'package:em2rp/providers/container_provider.dart'; import 'package:em2rp/providers/maintenance_provider.dart'; import 'package:em2rp/providers/alert_provider.dart'; import 'package:em2rp/utils/auth_guard_widget.dart'; import 'package:em2rp/views/calendar_page.dart'; import 'package:em2rp/views/login_page.dart'; import 'package:em2rp/views/equipment_management_page.dart'; +import 'package:em2rp/views/container_management_page.dart'; +import 'package:em2rp/views/container_form_page.dart'; +import 'package:em2rp/views/container_detail_page.dart'; +import 'package:em2rp/models/container_model.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:firebase_core/firebase_core.dart'; @@ -52,6 +57,9 @@ void main() async { ChangeNotifierProvider( create: (context) => EquipmentProvider(), ), + ChangeNotifierProvider( + create: (context) => ContainerProvider(), + ), ChangeNotifierProvider( create: (context) => MaintenanceProvider(), ), @@ -123,6 +131,25 @@ class MyApp extends StatelessWidget { '/equipment_management': (context) => const AuthGuard( requiredPermission: "view_equipment", child: EquipmentManagementPage()), + '/container_management': (context) => const AuthGuard( + requiredPermission: "view_equipment", + child: ContainerManagementPage()), + '/container_form': (context) { + final args = ModalRoute.of(context)?.settings.arguments; + return AuthGuard( + requiredPermission: "manage_equipment", + child: ContainerFormPage( + container: args as ContainerModel?, + ), + ); + }, + '/container_detail': (context) { + final container = ModalRoute.of(context)!.settings.arguments as ContainerModel; + return AuthGuard( + requiredPermission: "view_equipment", + child: ContainerDetailPage(container: container), + ); + }, }, ); } diff --git a/em2rp/lib/mixins/selection_mode_mixin.dart b/em2rp/lib/mixins/selection_mode_mixin.dart new file mode 100644 index 0000000..aa7fe1f --- /dev/null +++ b/em2rp/lib/mixins/selection_mode_mixin.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; + +/// Mixin réutilisable pour gérer le mode sélection multiple +/// Utilisable dans equipment_management_page, container_management_page, etc. +mixin SelectionModeMixin on State { + // État du mode sélection + bool _isSelectionMode = false; + final Set _selectedIds = {}; + + // Getters + bool get isSelectionMode => _isSelectionMode; + Set get selectedIds => _selectedIds; + int get selectedCount => _selectedIds.length; + bool get hasSelection => _selectedIds.isNotEmpty; + + /// Active/désactive le mode sélection + void toggleSelectionMode() { + setState(() { + _isSelectionMode = !_isSelectionMode; + if (!_isSelectionMode) { + _selectedIds.clear(); + } + }); + } + + /// Active le mode sélection + void enableSelectionMode() { + if (!_isSelectionMode) { + setState(() { + _isSelectionMode = true; + }); + } + } + + /// Désactive le mode sélection et efface la sélection + void disableSelectionMode() { + if (_isSelectionMode) { + setState(() { + _isSelectionMode = false; + _selectedIds.clear(); + }); + } + } + + /// Toggle la sélection d'un item + void toggleItemSelection(String id) { + setState(() { + if (_selectedIds.contains(id)) { + _selectedIds.remove(id); + } else { + _selectedIds.add(id); + } + }); + } + + /// Sélectionne un item + void selectItem(String id) { + setState(() { + _selectedIds.add(id); + }); + } + + /// Désélectionne un item + void deselectItem(String id) { + setState(() { + _selectedIds.remove(id); + }); + } + + /// Vérifie si un item est sélectionné + bool isItemSelected(String id) { + return _selectedIds.contains(id); + } + + /// Sélectionne tous les items + void selectAll(List ids) { + setState(() { + _selectedIds.addAll(ids); + }); + } + + /// Efface la sélection + void clearSelection() { + setState(() { + _selectedIds.clear(); + }); + } + + /// Sélectionne/désélectionne tous les items + void toggleSelectAll(List ids) { + setState(() { + if (_selectedIds.length == ids.length) { + // Tout est sélectionné, on désélectionne tout + _selectedIds.clear(); + } else { + // Sélectionner tout + _selectedIds.addAll(ids); + } + }); + } + + /// Widget pour afficher le nombre d'éléments sélectionnés + Widget buildSelectionCounter({ + required Color backgroundColor, + required Color textColor, + String? customText, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + customText ?? '$selectedCount sélectionné${selectedCount > 1 ? 's' : ''}', + style: TextStyle( + color: textColor, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + /// AppBar pour le mode sélection + PreferredSizeWidget buildSelectionAppBar({ + required String title, + required List actions, + Color? backgroundColor, + }) { + return AppBar( + backgroundColor: backgroundColor, + leading: IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: disableSelectionMode, + ), + title: Text( + '$selectedCount $title sélectionné${selectedCount > 1 ? 's' : ''}', + style: const TextStyle(color: Colors.white), + ), + actions: actions, + ); + } +} + diff --git a/em2rp/lib/models/container_model.dart b/em2rp/lib/models/container_model.dart new file mode 100644 index 0000000..b414d2b --- /dev/null +++ b/em2rp/lib/models/container_model.dart @@ -0,0 +1,251 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:em2rp/models/equipment_model.dart'; + +/// Type de container +enum ContainerType { + flightCase, // Flight case + pelicase, // Pelicase + bag, // Sac + openCrate, // Caisse ouverte + toolbox, // Boîte à outils +} + +String containerTypeToString(ContainerType type) { + switch (type) { + case ContainerType.flightCase: + return 'FLIGHT_CASE'; + case ContainerType.pelicase: + return 'PELICASE'; + case ContainerType.bag: + return 'BAG'; + case ContainerType.openCrate: + return 'OPEN_CRATE'; + case ContainerType.toolbox: + return 'TOOLBOX'; + } +} + +ContainerType containerTypeFromString(String? type) { + switch (type) { + case 'FLIGHT_CASE': + return ContainerType.flightCase; + case 'PELICASE': + return ContainerType.pelicase; + case 'BAG': + return ContainerType.bag; + case 'OPEN_CRATE': + return ContainerType.openCrate; + case 'TOOLBOX': + return ContainerType.toolbox; + default: + return ContainerType.flightCase; + } +} + +String containerTypeLabel(ContainerType type) { + switch (type) { + case ContainerType.flightCase: + return 'Flight Case'; + case ContainerType.pelicase: + return 'Pelicase'; + case ContainerType.bag: + return 'Sac'; + case ContainerType.openCrate: + return 'Caisse Ouverte'; + case ContainerType.toolbox: + return 'Boîte à Outils'; + } +} + +/// Modèle de container/boîte pour le matériel +class ContainerModel { + final String id; // Identifiant unique (généré comme pour équipement) + final String name; // Nom du container + final ContainerType type; // Type de container + final EquipmentStatus status; // Statut actuel (même que équipement) + + // Caractéristiques physiques + final double? weight; // Poids à vide (kg) + final double? length; // Longueur (cm) + final double? width; // Largeur (cm) + final double? height; // Hauteur (cm) + + // Contenu + final List equipmentIds; // IDs des équipements contenus + + // Événement + final String? eventId; // ID de l'événement actuel (si en prestation) + + // Métadonnées + final String? notes; // Notes additionnelles + final DateTime createdAt; // Date de création + final DateTime updatedAt; // Date de mise à jour + + // Historique simple (optionnel) + final List history; // Historique des modifications + + ContainerModel({ + required this.id, + required this.name, + required this.type, + this.status = EquipmentStatus.available, + this.weight, + this.length, + this.width, + this.height, + this.equipmentIds = const [], + this.eventId, + this.notes, + required this.createdAt, + required this.updatedAt, + this.history = const [], + }); + + /// Vérifier si le container est vide + bool get isEmpty => equipmentIds.isEmpty; + + /// Nombre d'équipements dans le container + int get itemCount => equipmentIds.length; + + /// Calculer le volume (m³) + double? get volume { + if (length == null || width == null || height == null) return null; + return (length! * width! * height!) / 1000000; // cm³ to m³ + } + + /// Calculer le poids total (poids vide + équipements) + /// Nécessite la liste des équipements + double calculateTotalWeight(List equipment) { + double total = weight ?? 0.0; + for (final eq in equipment) { + if (equipmentIds.contains(eq.id) && eq.weight != null) { + total += eq.weight!; + } + } + return total; + } + + /// Factory depuis Firestore + factory ContainerModel.fromMap(Map map, String id) { + final List equipmentIdsRaw = map['equipmentIds'] ?? []; + final List equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList(); + + final List historyRaw = map['history'] ?? []; + final List history = historyRaw + .map((e) => ContainerHistoryEntry.fromMap(e as Map)) + .toList(); + + return ContainerModel( + id: id, + name: map['name'] ?? '', + type: containerTypeFromString(map['type']), + status: equipmentStatusFromString(map['status']), + weight: map['weight']?.toDouble(), + length: map['length']?.toDouble(), + width: map['width']?.toDouble(), + height: map['height']?.toDouble(), + equipmentIds: equipmentIds, + eventId: map['eventId'], + notes: map['notes'], + createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(), + updatedAt: (map['updatedAt'] as Timestamp?)?.toDate() ?? DateTime.now(), + history: history, + ); + } + + /// Convertir en Map pour Firestore + Map toMap() { + return { + 'name': name, + 'type': containerTypeToString(type), + 'status': equipmentStatusToString(status), + 'weight': weight, + 'length': length, + 'width': width, + 'height': height, + 'equipmentIds': equipmentIds, + 'eventId': eventId, + 'notes': notes, + 'createdAt': Timestamp.fromDate(createdAt), + 'updatedAt': Timestamp.fromDate(updatedAt), + 'history': history.map((e) => e.toMap()).toList(), + }; + } + + /// Copier avec modifications + ContainerModel copyWith({ + String? id, + String? name, + ContainerType? type, + EquipmentStatus? status, + double? weight, + double? length, + double? width, + double? height, + List? equipmentIds, + String? eventId, + String? notes, + DateTime? createdAt, + DateTime? updatedAt, + List? history, + }) { + return ContainerModel( + id: id ?? this.id, + name: name ?? this.name, + type: type ?? this.type, + status: status ?? this.status, + weight: weight ?? this.weight, + length: length ?? this.length, + width: width ?? this.width, + height: height ?? this.height, + equipmentIds: equipmentIds ?? this.equipmentIds, + eventId: eventId ?? this.eventId, + notes: notes ?? this.notes, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + history: history ?? this.history, + ); + } +} + +/// Entrée d'historique pour un container +class ContainerHistoryEntry { + final DateTime timestamp; + final String action; // 'added', 'removed', 'status_change', etc. + final String? equipmentId; // ID de l'équipement concerné (si applicable) + final String? previousValue; // Valeur précédente + final String? newValue; // Nouvelle valeur + final String? userId; // ID de l'utilisateur ayant fait la modification + + ContainerHistoryEntry({ + required this.timestamp, + required this.action, + this.equipmentId, + this.previousValue, + this.newValue, + this.userId, + }); + + factory ContainerHistoryEntry.fromMap(Map map) { + return ContainerHistoryEntry( + timestamp: (map['timestamp'] as Timestamp?)?.toDate() ?? DateTime.now(), + action: map['action'] ?? '', + equipmentId: map['equipmentId'], + previousValue: map['previousValue'], + newValue: map['newValue'], + userId: map['userId'], + ); + } + + Map toMap() { + return { + 'timestamp': Timestamp.fromDate(timestamp), + 'action': action, + 'equipmentId': equipmentId, + 'previousValue': previousValue, + 'newValue': newValue, + 'userId': userId, + }; + } +} + diff --git a/em2rp/lib/models/equipment_model.dart b/em2rp/lib/models/equipment_model.dart index 78dea46..4d0d91f 100644 --- a/em2rp/lib/models/equipment_model.dart +++ b/em2rp/lib/models/equipment_model.dart @@ -119,6 +119,12 @@ class EquipmentModel { // Boîtes parentes (plusieurs possibles) final List parentBoxIds; // IDs des boîtes contenant cet équipement + // Caractéristiques physiques + final double? weight; // Poids (kg) + final double? length; // Longueur (cm) + final double? width; // Largeur (cm) + final double? height; // Hauteur (cm) + // Dates & maintenance final DateTime? purchaseDate; // Date d'achat final DateTime? lastMaintenanceDate; // Dernière maintenance @@ -148,6 +154,10 @@ class EquipmentModel { this.availableQuantity, this.criticalThreshold, this.parentBoxIds = const [], + this.weight, + this.length, + this.width, + this.height, this.purchaseDate, this.lastMaintenanceDate, this.nextMaintenanceDate, @@ -179,6 +189,10 @@ class EquipmentModel { availableQuantity: map['availableQuantity']?.toInt(), criticalThreshold: map['criticalThreshold']?.toInt(), parentBoxIds: parentBoxIds, + weight: map['weight']?.toDouble(), + length: map['length']?.toDouble(), + width: map['width']?.toDouble(), + height: map['height']?.toDouble(), purchaseDate: (map['purchaseDate'] as Timestamp?)?.toDate(), nextMaintenanceDate: (map['nextMaintenanceDate'] as Timestamp?)?.toDate(), maintenanceIds: maintenanceIds, @@ -202,6 +216,10 @@ class EquipmentModel { 'availableQuantity': availableQuantity, 'criticalThreshold': criticalThreshold, 'parentBoxIds': parentBoxIds, + 'weight': weight, + 'length': length, + 'width': width, + 'height': height, 'lastMaintenanceDate': lastMaintenanceDate != null ? Timestamp.fromDate(lastMaintenanceDate!) : null, 'purchaseDate': purchaseDate != null ? Timestamp.fromDate(purchaseDate!) : null, 'nextMaintenanceDate': nextMaintenanceDate != null ? Timestamp.fromDate(nextMaintenanceDate!) : null, @@ -226,6 +244,10 @@ class EquipmentModel { int? availableQuantity, int? criticalThreshold, List? parentBoxIds, + double? weight, + double? length, + double? width, + double? height, DateTime? purchaseDate, DateTime? lastMaintenanceDate, DateTime? nextMaintenanceDate, @@ -248,6 +270,10 @@ class EquipmentModel { availableQuantity: availableQuantity ?? this.availableQuantity, criticalThreshold: criticalThreshold ?? this.criticalThreshold, parentBoxIds: parentBoxIds ?? this.parentBoxIds, + weight: weight ?? this.weight, + length: length ?? this.length, + width: width ?? this.width, + height: height ?? this.height, lastMaintenanceDate: lastMaintenanceDate ?? this.lastMaintenanceDate, purchaseDate: purchaseDate ?? this.purchaseDate, nextMaintenanceDate: nextMaintenanceDate ?? this.nextMaintenanceDate, diff --git a/em2rp/lib/providers/container_provider.dart b/em2rp/lib/providers/container_provider.dart new file mode 100644 index 0000000..47ac2f0 --- /dev/null +++ b/em2rp/lib/providers/container_provider.dart @@ -0,0 +1,165 @@ +import 'package:flutter/foundation.dart'; +import 'package:em2rp/models/container_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/services/container_service.dart'; + +class ContainerProvider with ChangeNotifier { + final ContainerService _containerService = ContainerService(); + + ContainerType? _selectedType; + EquipmentStatus? _selectedStatus; + String _searchQuery = ''; + + ContainerType? get selectedType => _selectedType; + EquipmentStatus? get selectedStatus => _selectedStatus; + String get searchQuery => _searchQuery; + + /// Stream des containers avec filtres appliqués + Stream> get containersStream { + return _containerService.getContainers( + type: _selectedType, + status: _selectedStatus, + searchQuery: _searchQuery, + ); + } + + /// Définir le type sélectionné + void setSelectedType(ContainerType? type) { + _selectedType = type; + notifyListeners(); + } + + /// Définir le statut sélectionné + void setSelectedStatus(EquipmentStatus? status) { + _selectedStatus = status; + notifyListeners(); + } + + /// Définir la requête de recherche + void setSearchQuery(String query) { + _searchQuery = query; + notifyListeners(); + } + + /// Créer un nouveau container + Future createContainer(ContainerModel container) async { + await _containerService.createContainer(container); + notifyListeners(); + } + + /// Mettre à jour un container + Future updateContainer(String id, Map data) async { + await _containerService.updateContainer(id, data); + notifyListeners(); + } + + /// Supprimer un container + Future deleteContainer(String id) async { + await _containerService.deleteContainer(id); + notifyListeners(); + } + + /// Récupérer un container par ID + Future getContainerById(String id) async { + return await _containerService.getContainerById(id); + } + + /// Ajouter un équipement à un container + Future> addEquipmentToContainer({ + required String containerId, + required String equipmentId, + String? userId, + }) async { + final result = await _containerService.addEquipmentToContainer( + containerId: containerId, + equipmentId: equipmentId, + userId: userId, + ); + notifyListeners(); + return result; + } + + /// Retirer un équipement d'un container + Future removeEquipmentFromContainer({ + required String containerId, + required String equipmentId, + String? userId, + }) async { + await _containerService.removeEquipmentFromContainer( + containerId: containerId, + equipmentId: equipmentId, + userId: userId, + ); + notifyListeners(); + } + + /// Vérifier la disponibilité d'un container + Future> checkContainerAvailability({ + required String containerId, + required DateTime startDate, + required DateTime endDate, + String? excludeEventId, + }) async { + return await _containerService.checkContainerAvailability( + containerId: containerId, + startDate: startDate, + endDate: endDate, + excludeEventId: excludeEventId, + ); + } + + /// Récupérer les équipements d'un container + Future> getContainerEquipment(String containerId) async { + return await _containerService.getContainerEquipment(containerId); + } + + /// Trouver tous les containers contenant un équipement + Future> findContainersWithEquipment(String equipmentId) async { + return await _containerService.findContainersWithEquipment(equipmentId); + } + + /// Vérifier si un ID existe + Future checkContainerIdExists(String id) async { + return await _containerService.checkContainerIdExists(id); + } + + /// Générer un ID unique pour un container + /// Format: BOX_{TYPE}_{NAME}_{NUMBER} + static String generateContainerId({ + required ContainerType type, + required String name, + int? number, + }) { + // Obtenir le type en majuscules + final typeStr = containerTypeToString(type); + + // Nettoyer le nom (enlever espaces, caractères spéciaux) + final cleanName = name + .replaceAll(' ', '_') + .replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '') + .toUpperCase(); + + if (number != null) { + return 'BOX_${typeStr}_${cleanName}_#$number'; + } + + return 'BOX_${typeStr}_$cleanName'; + } + + /// Assurer l'unicité d'un ID de container + static Future ensureUniqueContainerId( + String baseId, + ContainerService service, + ) async { + String uniqueId = baseId; + int counter = 1; + + while (await service.checkContainerIdExists(uniqueId)) { + uniqueId = '${baseId}_$counter'; + counter++; + } + + return uniqueId; + } +} + diff --git a/em2rp/lib/services/container_pdf_generator_service.dart b/em2rp/lib/services/container_pdf_generator_service.dart new file mode 100644 index 0000000..22c9151 --- /dev/null +++ b/em2rp/lib/services/container_pdf_generator_service.dart @@ -0,0 +1,52 @@ +import 'dart:typed_data'; +import 'package:em2rp/models/container_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/services/unified_pdf_generator_service.dart'; + +export 'package:em2rp/services/unified_pdf_generator_service.dart' show QRLabelFormat; + +/// Formats d'étiquettes disponibles pour containers (legacy - utilise QRLabelFormat maintenant) +@Deprecated('Utiliser QRLabelFormat directement') +typedef ContainerQRLabelFormat = QRLabelFormat; + +/// Service pour la génération de PDFs avec QR codes pour containers +/// WRAPPER LEGACY - Utilise maintenant UnifiedPDFGeneratorService +@Deprecated('Utiliser UnifiedPDFGeneratorService directement') +class ContainerPDFGeneratorService { + /// Génère un PDF avec des QR codes selon le format choisi + static Future generateQRCodesPDF({ + required List containerList, + required Map> containerEquipmentMap, + required QRLabelFormat format, + }) async { + // Pour les grandes étiquettes, inclure les équipements + if (format == QRLabelFormat.large) { + return UnifiedPDFGeneratorService.generateAdvancedQRCodesPDF( + items: containerList, + getId: (c) => c.id, + getTitle: (c) => c.name, + getSubtitle: (c) { + final equipment = containerEquipmentMap[c.id] ?? []; + final lines = [ + 'Contenu (${equipment.length}):', + ...equipment.take(5).map((eq) => '- ${eq.id}'), + if (equipment.length > 5) '... +${equipment.length - 5}', + ]; + return lines; + }, + format: format, + ); + } + + // Pour les petites et moyennes étiquettes, juste ID + nom + return UnifiedPDFGeneratorService.generateAdvancedQRCodesPDF( + items: containerList, + getId: (c) => c.id, + getTitle: (c) => c.name, + getSubtitle: null, + format: format, + ); + } +} + + diff --git a/em2rp/lib/services/container_service.dart b/em2rp/lib/services/container_service.dart new file mode 100644 index 0000000..15b34e3 --- /dev/null +++ b/em2rp/lib/services/container_service.dart @@ -0,0 +1,378 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:em2rp/models/container_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; + +class ContainerService { + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + + // Collection references + CollectionReference get _containersCollection => _firestore.collection('containers'); + CollectionReference get _equipmentCollection => _firestore.collection('equipments'); + + // CRUD Operations + + /// Créer un nouveau container + Future createContainer(ContainerModel container) async { + try { + await _containersCollection.doc(container.id).set(container.toMap()); + } catch (e) { + print('Error creating container: $e'); + rethrow; + } + } + + /// Mettre à jour un container + Future updateContainer(String id, Map data) async { + try { + data['updatedAt'] = Timestamp.fromDate(DateTime.now()); + await _containersCollection.doc(id).update(data); + } catch (e) { + print('Error updating container: $e'); + rethrow; + } + } + + /// Supprimer un container + Future deleteContainer(String id) async { + try { + // Récupérer le container pour obtenir les équipements + final container = await getContainerById(id); + if (container != null && container.equipmentIds.isNotEmpty) { + // Retirer le container des parentBoxIds de chaque équipement + for (final equipmentId in container.equipmentIds) { + final equipmentDoc = await _equipmentCollection.doc(equipmentId).get(); + if (equipmentDoc.exists) { + final equipment = EquipmentModel.fromMap( + equipmentDoc.data() as Map, + equipmentDoc.id, + ); + final updatedParents = equipment.parentBoxIds.where((boxId) => boxId != id).toList(); + await _equipmentCollection.doc(equipmentId).update({ + 'parentBoxIds': updatedParents, + 'updatedAt': Timestamp.fromDate(DateTime.now()), + }); + } + } + } + + await _containersCollection.doc(id).delete(); + } catch (e) { + print('Error deleting container: $e'); + rethrow; + } + } + + /// Récupérer un container par ID + Future getContainerById(String id) async { + try { + final doc = await _containersCollection.doc(id).get(); + if (doc.exists) { + return ContainerModel.fromMap(doc.data() as Map, doc.id); + } + return null; + } catch (e) { + print('Error getting container: $e'); + rethrow; + } + } + + /// Récupérer tous les containers + Stream> getContainers({ + ContainerType? type, + EquipmentStatus? status, + String? searchQuery, + }) { + try { + Query query = _containersCollection; + + // Filtre par type + if (type != null) { + query = query.where('type', isEqualTo: containerTypeToString(type)); + } + + // Filtre par statut + if (status != null) { + query = query.where('status', isEqualTo: equipmentStatusToString(status)); + } + + return query.snapshots().map((snapshot) { + List containerList = snapshot.docs + .map((doc) => ContainerModel.fromMap(doc.data() as Map, doc.id)) + .toList(); + + // Filtre par recherche texte (côté client) + if (searchQuery != null && searchQuery.isNotEmpty) { + final lowerSearch = searchQuery.toLowerCase(); + containerList = containerList.where((container) { + return container.name.toLowerCase().contains(lowerSearch) || + container.id.toLowerCase().contains(lowerSearch); + }).toList(); + } + + return containerList; + }); + } catch (e) { + print('Error getting containers: $e'); + rethrow; + } + } + + /// Ajouter un équipement à un container + Future> addEquipmentToContainer({ + required String containerId, + required String equipmentId, + String? userId, + }) async { + try { + // Récupérer le container + final container = await getContainerById(containerId); + if (container == null) { + return {'success': false, 'message': 'Container non trouvé'}; + } + + // Vérifier si l'équipement n'est pas déjà dans ce container + if (container.equipmentIds.contains(equipmentId)) { + return {'success': false, 'message': 'Cet équipement est déjà dans ce container'}; + } + + // Récupérer l'équipement pour vérifier s'il est déjà dans d'autres containers + final equipmentDoc = await _equipmentCollection.doc(equipmentId).get(); + if (!equipmentDoc.exists) { + return {'success': false, 'message': 'Équipement non trouvé'}; + } + + final equipment = EquipmentModel.fromMap( + equipmentDoc.data() as Map, + equipmentDoc.id, + ); + + // Avertir si l'équipement est déjà dans d'autres containers + List otherContainers = []; + if (equipment.parentBoxIds.isNotEmpty) { + for (final boxId in equipment.parentBoxIds) { + final box = await getContainerById(boxId); + if (box != null) { + otherContainers.add(box.name); + } + } + } + + // Mettre à jour le container + final updatedEquipmentIds = [...container.equipmentIds, equipmentId]; + await updateContainer(containerId, { + 'equipmentIds': updatedEquipmentIds, + }); + + // Mettre à jour l'équipement + final updatedParentBoxIds = [...equipment.parentBoxIds, containerId]; + await _equipmentCollection.doc(equipmentId).update({ + 'parentBoxIds': updatedParentBoxIds, + 'updatedAt': Timestamp.fromDate(DateTime.now()), + }); + + // Ajouter une entrée dans l'historique + await _addHistoryEntry( + containerId: containerId, + action: 'equipment_added', + equipmentId: equipmentId, + newValue: equipmentId, + userId: userId, + ); + + return { + 'success': true, + 'message': 'Équipement ajouté avec succès', + 'warnings': otherContainers.isNotEmpty + ? 'Attention : cet équipement est également dans les containers suivants : ${otherContainers.join(", ")}' + : null, + }; + } catch (e) { + print('Error adding equipment to container: $e'); + return {'success': false, 'message': 'Erreur: $e'}; + } + } + + /// Retirer un équipement d'un container + Future removeEquipmentFromContainer({ + required String containerId, + required String equipmentId, + String? userId, + }) async { + try { + // Récupérer le container + final container = await getContainerById(containerId); + if (container == null) throw Exception('Container non trouvé'); + + // Mettre à jour le container + final updatedEquipmentIds = container.equipmentIds.where((id) => id != equipmentId).toList(); + await updateContainer(containerId, { + 'equipmentIds': updatedEquipmentIds, + }); + + // Mettre à jour l'équipement + final equipmentDoc = await _equipmentCollection.doc(equipmentId).get(); + if (equipmentDoc.exists) { + final equipment = EquipmentModel.fromMap( + equipmentDoc.data() as Map, + equipmentDoc.id, + ); + final updatedParentBoxIds = equipment.parentBoxIds.where((id) => id != containerId).toList(); + await _equipmentCollection.doc(equipmentId).update({ + 'parentBoxIds': updatedParentBoxIds, + 'updatedAt': Timestamp.fromDate(DateTime.now()), + }); + } + + // Ajouter une entrée dans l'historique + await _addHistoryEntry( + containerId: containerId, + action: 'equipment_removed', + equipmentId: equipmentId, + previousValue: equipmentId, + userId: userId, + ); + } catch (e) { + print('Error removing equipment from container: $e'); + rethrow; + } + } + + /// Vérifier la disponibilité d'un container et de son contenu pour un événement + Future> checkContainerAvailability({ + required String containerId, + required DateTime startDate, + required DateTime endDate, + String? excludeEventId, + }) async { + try { + final container = await getContainerById(containerId); + if (container == null) { + return {'available': false, 'message': 'Container non trouvé'}; + } + + // Vérifier le statut du container + if (container.status != EquipmentStatus.available) { + return { + 'available': false, + 'message': 'Container ${container.name} n\'est pas disponible (statut: ${container.status})', + }; + } + + // Vérifier la disponibilité de chaque équipement dans le container + List unavailableEquipment = []; + for (final equipmentId in container.equipmentIds) { + final equipmentDoc = await _equipmentCollection.doc(equipmentId).get(); + if (equipmentDoc.exists) { + final equipment = EquipmentModel.fromMap( + equipmentDoc.data() as Map, + equipmentDoc.id, + ); + + if (equipment.status != EquipmentStatus.available) { + unavailableEquipment.add('${equipment.name} (${equipment.status})'); + } + } + } + + if (unavailableEquipment.isNotEmpty) { + return { + 'available': false, + 'message': 'Certains équipements ne sont pas disponibles', + 'unavailableItems': unavailableEquipment, + }; + } + + return {'available': true, 'message': 'Container et tout son contenu disponibles'}; + } catch (e) { + print('Error checking container availability: $e'); + return {'available': false, 'message': 'Erreur: $e'}; + } + } + + /// Récupérer les équipements d'un container + Future> getContainerEquipment(String containerId) async { + try { + final container = await getContainerById(containerId); + if (container == null) return []; + + List equipment = []; + for (final equipmentId in container.equipmentIds) { + final doc = await _equipmentCollection.doc(equipmentId).get(); + if (doc.exists) { + equipment.add(EquipmentModel.fromMap(doc.data() as Map, doc.id)); + } + } + + return equipment; + } catch (e) { + print('Error getting container equipment: $e'); + rethrow; + } + } + + /// Trouver tous les containers contenant un équipement spécifique + Future> findContainersWithEquipment(String equipmentId) async { + try { + final snapshot = await _containersCollection + .where('equipmentIds', arrayContains: equipmentId) + .get(); + + return snapshot.docs + .map((doc) => ContainerModel.fromMap(doc.data() as Map, doc.id)) + .toList(); + } catch (e) { + print('Error finding containers with equipment: $e'); + rethrow; + } + } + + /// Ajouter une entrée d'historique + Future _addHistoryEntry({ + required String containerId, + required String action, + String? equipmentId, + String? previousValue, + String? newValue, + String? userId, + }) async { + try { + final container = await getContainerById(containerId); + if (container == null) return; + + final entry = ContainerHistoryEntry( + timestamp: DateTime.now(), + action: action, + equipmentId: equipmentId, + previousValue: previousValue, + newValue: newValue, + userId: userId, + ); + + final updatedHistory = [...container.history, entry]; + + // Limiter l'historique aux 100 dernières entrées + final limitedHistory = updatedHistory.length > 100 + ? updatedHistory.sublist(updatedHistory.length - 100) + : updatedHistory; + + await updateContainer(containerId, { + 'history': limitedHistory.map((e) => e.toMap()).toList(), + }); + } catch (e) { + print('Error adding history entry: $e'); + // Ne pas throw pour éviter de bloquer l'opération principale + } + } + + /// Vérifier si un ID de container existe déjà + Future checkContainerIdExists(String id) async { + try { + final doc = await _containersCollection.doc(id).get(); + return doc.exists; + } catch (e) { + print('Error checking container ID: $e'); + return false; + } + } +} + diff --git a/em2rp/lib/services/equipment_service.dart b/em2rp/lib/services/equipment_service.dart index c387ad5..0db0310 100644 --- a/em2rp/lib/services/equipment_service.dart +++ b/em2rp/lib/services/equipment_service.dart @@ -1,6 +1,7 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/alert_model.dart'; +import 'package:em2rp/models/maintenance_model.dart'; class EquipmentService { final FirebaseFirestore _firestore = FirebaseFirestore.instance; @@ -369,5 +370,63 @@ class EquipmentService { rethrow; } } + + /// Récupérer plusieurs équipements par leurs IDs + Future> getEquipmentsByIds(List ids) async { + try { + if (ids.isEmpty) return []; + + final equipments = []; + + // Firestore limite les requêtes whereIn à 10 éléments + // On doit donc diviser en plusieurs requêtes si nécessaire + for (int i = 0; i < ids.length; i += 10) { + final batch = ids.skip(i).take(10).toList(); + final query = await _equipmentCollection + .where(FieldPath.documentId, whereIn: batch) + .get(); + + for (var doc in query.docs) { + equipments.add( + EquipmentModel.fromMap( + doc.data() as Map, + doc.id, + ), + ); + } + } + + return equipments; + } catch (e) { + print('Error getting equipments by IDs: $e'); + rethrow; + } + } + + /// Récupérer les maintenances pour un équipement + Future> getMaintenancesForEquipment(String equipmentId) async { + try { + final maintenanceQuery = await _firestore + .collection('maintenances') + .where('equipmentIds', arrayContains: equipmentId) + .orderBy('scheduledDate', descending: true) + .get(); + + final maintenances = []; + for (var doc in maintenanceQuery.docs) { + maintenances.add( + MaintenanceModel.fromMap( + doc.data(), + doc.id, + ), + ); + } + + return maintenances; + } catch (e) { + print('Error getting maintenances for equipment: $e'); + rethrow; + } + } } diff --git a/em2rp/lib/services/pdf_generator_service.dart b/em2rp/lib/services/pdf_generator_service.dart new file mode 100644 index 0000000..8e0e44a --- /dev/null +++ b/em2rp/lib/services/pdf_generator_service.dart @@ -0,0 +1,76 @@ +import 'dart:typed_data'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/services/unified_pdf_generator_service.dart'; + +// Export QRLabelFormat pour rétrocompatibilité +export 'package:em2rp/services/unified_pdf_generator_service.dart' show QRLabelFormat; + +/// Service pour la génération de PDFs avec QR codes pour équipements +/// WRAPPER LEGACY - Utilise maintenant UnifiedPDFGeneratorService +@Deprecated('Utiliser UnifiedPDFGeneratorService directement') +class PDFGeneratorService { + /// Génère un PDF avec des QR codes selon le format choisi + static Future generateQRCodesPDF({ + required List equipmentList, + required QRLabelFormat format, + }) async { + // Pour les grandes étiquettes, ajouter les détails + if (format == QRLabelFormat.large) { + return UnifiedPDFGeneratorService.generateAdvancedQRCodesPDF( + items: equipmentList, + getId: (eq) => eq.id, + getTitle: (eq) => '${eq.brand ?? ''} ${eq.model ?? ''}'.trim(), + getSubtitle: (eq) { + final details = []; + + // Marque + if (eq.brand != null && eq.brand!.isNotEmpty) { + details.add('Marque: ${eq.brand}'); + } + + // Modèle + if (eq.model != null && eq.model!.isNotEmpty) { + details.add('Modèle: ${eq.model}'); + } + + // Catégorie + details.add('Catégorie: ${_getCategoryLabel(eq.category)}'); + + return details; + }, + format: format, + ); + } + + // Pour petites et moyennes étiquettes, juste ID + marque/modèle + return UnifiedPDFGeneratorService.generateAdvancedQRCodesPDF( + items: equipmentList, + getId: (eq) => eq.id, + getTitle: (eq) => '${eq.brand ?? ''} ${eq.model ?? ''}'.trim(), + getSubtitle: null, + format: format, + ); + } + + static String _getCategoryLabel(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.lighting: + return 'Lumière'; + case EquipmentCategory.sound: + return 'Son'; + case EquipmentCategory.video: + return 'Vidéo'; + case EquipmentCategory.effect: + return 'Effets'; + case EquipmentCategory.structure: + return 'Structure'; + case EquipmentCategory.cable: + return 'Câble'; + case EquipmentCategory.consumable: + return 'Consommable'; + case EquipmentCategory.other: + return 'Autre'; + } + } +} + diff --git a/em2rp/lib/services/qr_code_service.dart b/em2rp/lib/services/qr_code_service.dart new file mode 100644 index 0000000..4dddde9 --- /dev/null +++ b/em2rp/lib/services/qr_code_service.dart @@ -0,0 +1,173 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; +import 'package:flutter/services.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:em2rp/utils/colors.dart'; + +/// Service pour la génération de QR codes optimisée +class QRCodeService { + // Cache pour éviter de régénérer les mêmes QR codes + static final Map _qrCache = {}; + static ui.Image? _cachedLogoImage; + + /// Génère un QR code simple sans logo + static Future generateQRCode( + String data, { + double size = 512, + bool useCache = true, + }) async { + // Vérifier le cache + if (useCache && _qrCache.containsKey(data)) { + return _qrCache[data]!; + } + + final qrValidationResult = QrValidator.validate( + data: data, + version: QrVersions.auto, + errorCorrectionLevel: QrErrorCorrectLevel.L, + ); + + if (qrValidationResult.status != QrValidationStatus.valid) { + throw Exception('QR code validation failed for data: $data'); + } + + final qrCode = qrValidationResult.qrCode!; + final painter = QrPainter.withQr( + qr: qrCode, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.square, + color: AppColors.noir, + ), + gapless: true, + ); + + final picData = await painter.toImageData(size, format: ui.ImageByteFormat.png); + final bytes = picData!.buffer.asUint8List(); + + // Mettre en cache + if (useCache) { + _qrCache[data] = bytes; + } + + return bytes; + } + + /// Génère un QR code avec logo embarqué + static Future generateQRCodeWithLogo( + String data, { + double size = 512, + bool useCache = true, + }) async { + final cacheKey = '${data}_logo'; + + // Vérifier le cache + if (useCache && _qrCache.containsKey(cacheKey)) { + return _qrCache[cacheKey]!; + } + + final qrValidationResult = QrValidator.validate( + data: data, + version: QrVersions.auto, + errorCorrectionLevel: QrErrorCorrectLevel.L, + ); + + if (qrValidationResult.status != QrValidationStatus.valid) { + throw Exception('QR code validation failed for data: $data'); + } + + final qrCode = qrValidationResult.qrCode!; + final embedded = await _loadLogoImage(); + + final painter = QrPainter.withQr( + qr: qrCode, + embeddedImage: embedded, + embeddedImageStyle: const QrEmbeddedImageStyle( + size: Size(80, 80), + ), + gapless: true, + ); + + final picData = await painter.toImageData(size, format: ui.ImageByteFormat.png); + final bytes = picData!.buffer.asUint8List(); + + // Mettre en cache + if (useCache) { + _qrCache[cacheKey] = bytes; + } + + return bytes; + } + + /// Charge le logo depuis les assets (avec cache) + static Future _loadLogoImage() async { + if (_cachedLogoImage != null) { + return _cachedLogoImage!; + } + + final data = await rootBundle.load('assets/logos/SquareLogoBlack.png'); + final codec = await ui.instantiateImageCodec(data.buffer.asUint8List()); + final frame = await codec.getNextFrame(); + _cachedLogoImage = frame.image; + return _cachedLogoImage!; + } + + /// Génère plusieurs QR codes en parallèle (optimisé) + static Future> generateBulkQRCodes( + List dataList, { + double size = 512, + bool withLogo = false, + bool useCache = true, + }) async { + // Si tout est en cache, retourner immédiatement + if (useCache) { + final allCached = dataList.every((data) { + final key = withLogo ? '${data}_logo' : data; + return _qrCache.containsKey(key); + }); + + if (allCached) { + return dataList.map((data) { + final key = withLogo ? '${data}_logo' : data; + return _qrCache[key]!; + }).toList(); + } + } + + // Batching adaptatif optimisé selon la taille et le nombre + int batchSize; + if (size <= 200) { + batchSize = 100; // Petits QR : lots de 100 + } else if (size <= 300) { + batchSize = 50; // Moyens QR : lots de 50 + } else if (size <= 500) { + batchSize = 20; // Grands QR : lots de 20 + } else { + batchSize = 10; // Très grands : lots de 10 + } + + final List results = []; + + for (int i = 0; i < dataList.length; i += batchSize) { + final batch = dataList.skip(i).take(batchSize).toList(); + final batchResults = await Future.wait( + batch.map((data) => withLogo + ? generateQRCodeWithLogo(data, size: size, useCache: useCache) + : generateQRCode(data, size: size, useCache: useCache)), + ); + results.addAll(batchResults); + } + + return results; + } + + /// Vide le cache des QR codes + static void clearCache() { + _qrCache.clear(); + } + + /// Obtient la taille du cache + static int getCacheSize() { + return _qrCache.length; + } +} + diff --git a/em2rp/lib/services/unified_pdf_generator_service.dart b/em2rp/lib/services/unified_pdf_generator_service.dart new file mode 100644 index 0000000..e8136b5 --- /dev/null +++ b/em2rp/lib/services/unified_pdf_generator_service.dart @@ -0,0 +1,354 @@ +import 'dart:typed_data'; +import 'package:flutter/services.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:em2rp/services/qr_code_service.dart'; + +/// Formats d'étiquettes QR disponibles +enum QRLabelFormat { small, medium, large } + +/// Interface pour les items qui peuvent avoir un QR code +abstract class QRCodeItem { + String get id; + String get displayName; +} + +/// Service unifié pour la génération de PDFs avec QR codes +/// Fonctionne avec n'importe quel type d'objet ayant un ID +class UnifiedPDFGeneratorService { + static Uint8List? _cachedLogoBytes; + + /// Tronque un texte s'il dépasse une longueur maximale + static String _truncateText(String text, int maxLength) { + if (text.length <= maxLength) { + return text; + } + return '${text.substring(0, maxLength - 3)}...'; + } + + /// Charge le logo en cache (optimisation) + static Future _ensureLogoLoaded() async { + if (_cachedLogoBytes == null) { + try { + final logoData = await rootBundle.load('assets/logos/LowQRectangleLogoBlack.png'); + _cachedLogoBytes = logoData.buffer.asUint8List(); + } catch (e) { + // Logo non disponible, on continue sans + _cachedLogoBytes = Uint8List(0); + } + } + } + + /// Génère un PDF avec des QR codes simples (juste ID + QR) + static Future generateSimpleQRCodesPDF({ + required List items, + required String Function(T) getId, + required QRLabelFormat format, + String Function(T)? getDisplayName, + }) async { + final pdf = pw.Document(); + + switch (format) { + case QRLabelFormat.small: + await _generateSmallQRCodesPDF(pdf, items, getId, getDisplayName); + break; + case QRLabelFormat.medium: + await _generateMediumQRCodesPDF(pdf, items, getId, getDisplayName); + break; + case QRLabelFormat.large: + await _generateLargeQRCodesPDF(pdf, items, getId, getDisplayName, null); + break; + } + + return pdf.save(); + } + + /// Génère un PDF avec des QR codes avancés (avec informations supplémentaires) + static Future generateAdvancedQRCodesPDF({ + required List items, + required String Function(T) getId, + required String Function(T) getTitle, + required List Function(T)? getSubtitle, + required QRLabelFormat format, + }) async { + final pdf = pw.Document(); + + switch (format) { + case QRLabelFormat.small: + await _generateSmallQRCodesPDF(pdf, items, getId, getTitle); + break; + case QRLabelFormat.medium: + await _generateMediumQRCodesPDF(pdf, items, getId, getTitle); + break; + case QRLabelFormat.large: + await _generateLargeQRCodesPDF(pdf, items, getId, getTitle, getSubtitle); + break; + } + + return pdf.save(); + } + + // ========================================================================== + // PETITS QR CODES (2x2 cm, 20 par page) + // ========================================================================== + + static Future _generateSmallQRCodesPDF( + pw.Document pdf, + List items, + String Function(T) getId, + String Function(T)? getDisplayName, + ) async { + const qrSize = 56.69; // 2cm en points + const itemsPerPage = 20; + + // Générer tous les QR codes en une fois (optimisé avec résolution réduite) + final allQRImages = await QRCodeService.generateBulkQRCodes( + items.map((item) => getId(item)).toList(), + size: 150, // Réduit de 200 à 150 pour performance optimale + withLogo: false, + useCache: true, + ); + + for (int pageStart = 0; pageStart < items.length; pageStart += itemsPerPage) { + final pageItems = items.skip(pageStart).take(itemsPerPage).toList(); + final pageQRImages = allQRImages.skip(pageStart).take(itemsPerPage).toList(); + + pdf.addPage( + pw.Page( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(20), + build: (context) { + return pw.Wrap( + spacing: 10, + runSpacing: 10, + children: List.generate(pageItems.length, (index) { + return pw.Container( + width: qrSize, + height: qrSize + 20, + child: pw.Column( + mainAxisAlignment: pw.MainAxisAlignment.center, + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Image(pw.MemoryImage(pageQRImages[index])), + pw.SizedBox(height: 2), + pw.Text( + getId(pageItems[index]), + style: pw.TextStyle( + fontSize: 6, + fontWeight: pw.FontWeight.bold, + ), + textAlign: pw.TextAlign.center, + ), + ], + ), + ); + }), + ); + }, + ), + ); + } + } + + // ========================================================================== + // QR CODES MOYENS (4x4 cm, 6 par page) + // ========================================================================== + + static Future _generateMediumQRCodesPDF( + pw.Document pdf, + List items, + String Function(T) getId, + String Function(T)? getDisplayName, + ) async { + const qrSize = 113.39; // 4cm en points + const itemsPerPage = 6; + + // Optimisé avec résolution réduite + final allQRImages = await QRCodeService.generateBulkQRCodes( + items.map((item) => getId(item)).toList(), + size: 250, // Réduit de 400 à 250 pour performance optimale + withLogo: false, + useCache: true, + ); + + for (int pageStart = 0; pageStart < items.length; pageStart += itemsPerPage) { + final pageItems = items.skip(pageStart).take(itemsPerPage).toList(); + final pageQRImages = allQRImages.skip(pageStart).take(itemsPerPage).toList(); + + pdf.addPage( + pw.Page( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(20), + build: (context) { + return pw.Wrap( + spacing: 20, + runSpacing: 20, + children: List.generate(pageItems.length, (index) { + return pw.Container( + width: qrSize, + height: qrSize + 30, + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.center, + mainAxisAlignment: pw.MainAxisAlignment.center, + children: [ + pw.Image(pw.MemoryImage(pageQRImages[index])), + pw.SizedBox(height: 4), + pw.Text( + getId(pageItems[index]), + style: pw.TextStyle( + fontSize: 10, + fontWeight: pw.FontWeight.bold, + ), + textAlign: pw.TextAlign.center, + ), + if (getDisplayName != null) ...[ + pw.SizedBox(height: 2), + pw.Text( + _truncateText(getDisplayName(pageItems[index]), 25), + style: const pw.TextStyle( + fontSize: 8, + color: PdfColors.grey700, + ), + textAlign: pw.TextAlign.center, + ), + ], + ], + ), + ); + }), + ); + }, + ), + ); + } + } + + // ========================================================================== + // GRANDES ÉTIQUETTES (QR + infos détaillées, 6 par page) + // ========================================================================== + + static Future _generateLargeQRCodesPDF( + pw.Document pdf, + List items, + String Function(T) getId, + String Function(T)? getTitle, + List Function(T)? getSubtitle, + ) async { + const qrSize = 100.0; + const itemsPerPage = 6; + + // Charger le logo une seule fois + await _ensureLogoLoaded(); + + // Générer les QR codes en bulk pour optimisation + final allQRImages = await QRCodeService.generateBulkQRCodes( + items.map((item) => getId(item)).toList(), + size: 300, // Réduit de 400 à 300 pour améliorer la performance + withLogo: false, + useCache: true, + ); + + for (int pageStart = 0; pageStart < items.length; pageStart += itemsPerPage) { + final pageItems = items.skip(pageStart).take(itemsPerPage).toList(); + final pageQRImages = allQRImages.skip(pageStart).take(itemsPerPage).toList(); + + pdf.addPage( + pw.Page( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(20), + build: (context) { + return pw.Wrap( + spacing: 10, + runSpacing: 10, + children: List.generate(pageItems.length, (index) { + final item = pageItems[index]; + return pw.Container( + width: 260, + height: 120, + decoration: pw.BoxDecoration( + border: pw.Border.all(color: PdfColors.grey400), + borderRadius: pw.BorderRadius.circular(4), + ), + padding: const pw.EdgeInsets.all(8), + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + // QR Code + pw.Container( + width: qrSize, + height: qrSize, + child: pw.Image( + pw.MemoryImage(pageQRImages[index]), + fit: pw.BoxFit.contain, + ), + ), + pw.SizedBox(width: 8), + // Informations + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + mainAxisAlignment: pw.MainAxisAlignment.start, + children: [ + // Logo - CENTRÉ ET PLUS GRAND + if (_cachedLogoBytes != null && _cachedLogoBytes!.isNotEmpty) + pw.Center( + child: pw.Container( + height: 25, // Augmenté de 15 à 25 + margin: const pw.EdgeInsets.only(bottom: 6), + child: pw.Image( + pw.MemoryImage(_cachedLogoBytes!), + fit: pw.BoxFit.contain, + ), + ), + ), + // ID (toujours affiché sur plusieurs lignes si nécessaire) + if (getTitle != null) ...[ + pw.SizedBox(height: 2), + pw.Text( + _truncateText(getTitle(item), 20), + style: pw.TextStyle( + fontSize: 10, + fontWeight: pw.FontWeight.bold, + ), + maxLines: 2, + ), + ], + pw.SizedBox(height: 2), + pw.Text( + getId(item), + style: const pw.TextStyle( + fontSize: 8, + color: PdfColors.grey700, + ), + maxLines: 1, + ), + if (getSubtitle != null) ...[ + pw.SizedBox(height: 4), + ...getSubtitle(item).take(5).map((line) { + return pw.Padding( + padding: const pw.EdgeInsets.only(bottom: 1), + child: pw.Text( + _truncateText(line, 25), + style: const pw.TextStyle( + fontSize: 6, + color: PdfColors.grey800, + ), + ), + ); + }), + ], + ], + ), + ), + ], + ), + ); + }), + ); + }, + ), + ); + } + } +} + diff --git a/em2rp/lib/utils/id_generator.dart b/em2rp/lib/utils/id_generator.dart new file mode 100644 index 0000000..34c059d --- /dev/null +++ b/em2rp/lib/utils/id_generator.dart @@ -0,0 +1,155 @@ +import 'package:em2rp/models/container_model.dart'; +import 'package:em2rp/services/equipment_service.dart'; + +/// Générateur d'identifiants unifié pour l'application +/// Gère les équipements, containers et autres entités +class IdGenerator { + // ============================================================================ + // ÉQUIPEMENTS + // ============================================================================ + + /// Génère un ID pour un équipement + /// Format: {Marque4Chars}_{Modèle}_{#Numéro} + /// Exemple: BEAM_7R_#1 + static String generateEquipmentId({ + required String brand, + required String model, + int? number, + }) { + final brandTrim = brand.trim().replaceAll(' ', '_'); + final modelTrim = model.trim().replaceAll(' ', '_'); + + if (brandTrim.isEmpty && modelTrim.isEmpty) { + return 'EQ-${DateTime.now().millisecondsSinceEpoch}${number != null ? '_$number' : ''}'; + } + + final brandPrefix = brandTrim.length >= 4 + ? brandTrim.substring(0, 4) + : brandTrim; + + String baseId = modelTrim.isNotEmpty + ? '${brandPrefix}_$modelTrim' + : (brandPrefix.isNotEmpty ? brandPrefix : 'EQ'); + + // Empêcher les ID commençant par BOX_ (réservé aux containers) + if (baseId.toUpperCase().startsWith('BOX_')) { + baseId = 'EQ_$baseId'; + } + + if (number != null) { + baseId += '_#$number'; + } + + return baseId.toUpperCase(); + } + + /// Garantit l'unicité d'un ID d'équipement + static Future ensureUniqueEquipmentId( + String baseId, + EquipmentService service, + ) async { + // Vérifier que l'ID ne commence pas par BOX_ + if (baseId.toUpperCase().startsWith('BOX_')) { + baseId = 'EQ_$baseId'; + } + + if (await service.isIdUnique(baseId)) { + return baseId; + } + + return '${baseId}_${DateTime.now().millisecondsSinceEpoch}'; + } + + // ============================================================================ + // CONTAINERS + // ============================================================================ + + /// Génère un ID pour un container + /// Format: BOX_{Type}_{Nom}_{#Numéro} + /// Exemple: BOX_FLIGHT_CASE_BEAM_#1 + static String generateContainerId({ + required ContainerType type, + required String name, + int? number, + }) { + final typeStr = containerTypeToString(type); + final cleanName = _cleanId(name); + + if (number != null) { + return 'BOX_${typeStr}_${cleanName}_#$number'; + } + + return 'BOX_${typeStr}_$cleanName'; + } + + /// Garantit l'unicité d'un ID de container + /// Note: La vérification d'unicité doit être faite par l'appelant + static String ensureUniqueContainerId(String baseId) { + // Retourne simplement l'ID de base + // L'unicité sera vérifiée au niveau du provider/form + return baseId; + } + + /// Génère un ID unique avec un timestamp si nécessaire + static String generateUniqueContainerId(String baseId) { + return '${baseId}_${DateTime.now().millisecondsSinceEpoch}'; + } + + + // ============================================================================ + // UTILITAIRES + // ============================================================================ + + /// Nettoie une chaîne pour en faire un ID valide + /// - Supprime les espaces (remplacés par _) + /// - Supprime les caractères spéciaux + /// - Met en majuscules + static String _cleanId(String input) { + return input + .trim() + .toUpperCase() + .replaceAll(' ', '_') + .replaceAll(RegExp(r'[^A-Z0-9_-]'), ''); + } + + /// Valide qu'un ID d'équipement ne commence pas par un préfixe réservé + static String? validateEquipmentId(String id) { + if (id.isEmpty) { + return 'L\'identifiant ne peut pas être vide'; + } + + if (id.toUpperCase().startsWith('BOX_')) { + return 'Les ID commençant par BOX_ sont réservés aux containers'; + } + + return null; + } + + /// Valide qu'un ID de container commence bien par BOX_ + static String? validateContainerId(String id) { + if (id.isEmpty) { + return 'L\'identifiant ne peut pas être vide'; + } + + if (!id.toUpperCase().startsWith('BOX_')) { + return 'Les containers doivent avoir un ID commençant par BOX_'; + } + + return null; + } + + /// Détermine le type d'entité à partir d'un ID + static EntityType getEntityType(String id) { + if (id.toUpperCase().startsWith('BOX_')) { + return EntityType.container; + } + return EntityType.equipment; + } +} + +/// Type d'entité identifiable +enum EntityType { + equipment, + container, +} + diff --git a/em2rp/lib/views/container_detail_page.dart b/em2rp/lib/views/container_detail_page.dart new file mode 100644 index 0000000..4a36937 --- /dev/null +++ b/em2rp/lib/views/container_detail_page.dart @@ -0,0 +1,793 @@ +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/models/equipment_model.dart'; +import 'package:em2rp/providers/container_provider.dart'; +import 'package:em2rp/views/equipment_detail_page.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:intl/intl.dart'; + +class ContainerDetailPage extends StatefulWidget { + final ContainerModel container; + + const ContainerDetailPage({super.key, required this.container}); + + @override + State createState() => _ContainerDetailPageState(); +} + +class _ContainerDetailPageState extends State { + late ContainerModel _container; + List _equipmentList = []; + bool _isLoadingEquipment = true; + + @override + void initState() { + super.initState(); + _container = widget.container; + _loadEquipment(); + } + + Future _loadEquipment() async { + setState(() { + _isLoadingEquipment = true; + }); + + try { + final containerProvider = context.read(); + final equipment = await containerProvider.getContainerEquipment(_container.id); + setState(() { + _equipmentList = equipment; + _isLoadingEquipment = false; + }); + } catch (e) { + setState(() { + _isLoadingEquipment = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur lors du chargement: $e')), + ); + } + } + } + + Future _refreshContainer() async { + final containerProvider = context.read(); + final updated = await containerProvider.getContainerById(_container.id); + if (updated != null) { + setState(() { + _container = updated; + }); + await _loadEquipment(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Détails du Container'), + backgroundColor: AppColors.rouge, + foregroundColor: Colors.white, + actions: [ + IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Modifier', + onPressed: _editContainer, + ), + IconButton( + icon: const Icon(Icons.qr_code), + tooltip: 'QR Code', + onPressed: _showQRCode, + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: _handleMenuAction, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete, color: Colors.red, size: 20), + SizedBox(width: 8), + Text('Supprimer', style: TextStyle(color: Colors.red)), + ], + ), + ), + ], + ), + ], + ), + body: RefreshIndicator( + onRefresh: _refreshContainer, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildHeaderCard(), + const SizedBox(height: 16), + _buildPhysicalCharacteristics(), + const SizedBox(height: 16), + _buildEquipmentSection(), + const SizedBox(height: 16), + if (_container.notes != null && _container.notes!.isNotEmpty) + _buildNotesSection(), + const SizedBox(height: 16), + _buildHistorySection(), + ], + ), + ), + ); + } + + Widget _buildHeaderCard() { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _getTypeIcon(_container.type), + size: 60, + color: AppColors.rouge, + ), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _container.id, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + _container.name, + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade700, + ), + ), + ], + ), + ), + ], + ), + const Divider(height: 32), + Row( + children: [ + Expanded( + child: _buildInfoItem( + 'Type', + containerTypeLabel(_container.type), + Icons.category, + ), + ), + Expanded( + child: _buildInfoItem( + 'Statut', + _getStatusLabel(_container.status), + Icons.info, + statusColor: _getStatusColor(_container.status), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildInfoItem( + 'Équipements', + '${_container.itemCount}', + Icons.inventory, + ), + ), + Expanded( + child: _buildInfoItem( + 'Poids total', + _calculateTotalWeight(), + Icons.scale, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildPhysicalCharacteristics() { + final hasDimensions = _container.length != null || + _container.width != null || + _container.height != null; + final hasWeight = _container.weight != null; + final hasVolume = _container.volume != null; + + if (!hasDimensions && !hasWeight) { + return const SizedBox.shrink(); + } + + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Caractéristiques physiques', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Divider(height: 24), + if (hasWeight) + _buildCharacteristicRow( + 'Poids à vide', + '${_container.weight} kg', + Icons.scale, + ), + if (hasDimensions) ...[ + if (hasWeight) const SizedBox(height: 12), + _buildCharacteristicRow( + 'Dimensions (L×l×H)', + '${_container.length ?? '?'} × ${_container.width ?? '?'} × ${_container.height ?? '?'} cm', + Icons.straighten, + ), + ], + if (hasVolume) ...[ + const SizedBox(height: 12), + _buildCharacteristicRow( + 'Volume', + '${_container.volume!.toStringAsFixed(3)} m³', + Icons.view_in_ar, + ), + ], + ], + ), + ), + ); + } + + Widget _buildEquipmentSection() { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Contenu du container', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const Divider(height: 24), + if (_isLoadingEquipment) + const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(), + ), + ) + else if (_equipmentList.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + children: [ + Icon( + Icons.inventory_2_outlined, + size: 60, + color: Colors.grey.shade400, + ), + const SizedBox(height: 12), + Text( + 'Aucun équipement dans ce container', + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + ) + else + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _equipmentList.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final equipment = _equipmentList[index]; + return _buildEquipmentTile(equipment); + }, + ), + ], + ), + ), + ); + } + + Widget _buildEquipmentTile(EquipmentModel equipment) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 8), + leading: CircleAvatar( + backgroundColor: AppColors.rouge.withOpacity(0.1), + child: const Icon(Icons.inventory_2, color: AppColors.rouge), + ), + title: Text( + equipment.id, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (equipment.brand != null || equipment.model != null) + Text('${equipment.brand ?? ''} ${equipment.model ?? ''}'), + const SizedBox(height: 4), + Row( + children: [ + _buildSmallBadge( + _getCategoryLabel(equipment.category), + Colors.blue, + ), + const SizedBox(width: 8), + if (equipment.weight != null) + _buildSmallBadge( + '${equipment.weight} kg', + Colors.grey, + ), + ], + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.visibility, size: 20), + tooltip: 'Voir détails', + onPressed: () => _viewEquipment(equipment), + ), + IconButton( + icon: const Icon(Icons.remove_circle, color: Colors.red, size: 20), + tooltip: 'Retirer', + onPressed: () => _removeEquipment(equipment), + ), + ], + ), + ); + } + + Widget _buildNotesSection() { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Notes', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Divider(height: 24), + Text( + _container.notes!, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ), + ); + } + + Widget _buildHistorySection() { + if (_container.history.isEmpty) { + return const SizedBox.shrink(); + } + + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Historique', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Divider(height: 24), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _container.history.length > 10 + ? 10 + : _container.history.length, + separatorBuilder: (context, index) => const Divider(height: 16), + itemBuilder: (context, index) { + final entry = _container.history[ + _container.history.length - 1 - index]; // Plus récent en premier + return _buildHistoryEntry(entry); + }, + ), + ], + ), + ), + ); + } + + Widget _buildHistoryEntry(ContainerHistoryEntry entry) { + IconData icon; + Color color; + String description; + + switch (entry.action) { + case 'equipment_added': + icon = Icons.add_circle; + color = Colors.green; + description = 'Équipement ajouté: ${entry.equipmentId ?? "?"}'; + break; + case 'equipment_removed': + icon = Icons.remove_circle; + color = Colors.red; + description = 'Équipement retiré: ${entry.equipmentId ?? "?"}'; + break; + case 'status_change': + icon = Icons.sync; + color = Colors.blue; + description = + 'Statut changé: ${entry.previousValue} → ${entry.newValue}'; + break; + default: + icon = Icons.info; + color = Colors.grey; + description = entry.action; + } + + return Row( + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + description, + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 2), + Text( + DateFormat('dd/MM/yyyy HH:mm').format(entry.timestamp), + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildInfoItem(String label, String value, IconData icon, + {Color? statusColor}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: Colors.grey.shade600), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: statusColor, + ), + ), + ], + ); + } + + Widget _buildCharacteristicRow(String label, String value, IconData icon) { + return Row( + children: [ + Icon(icon, size: 20, color: AppColors.rouge), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: const TextStyle(fontSize: 14), + ), + ), + Text( + value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } + + Widget _buildSmallBadge(String label, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + color: color, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + String _calculateTotalWeight() { + if (_equipmentList.isEmpty && _container.weight == null) { + return '-'; + } + + final totalWeight = _container.calculateTotalWeight(_equipmentList); + 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: + return 'Disponible'; + case EquipmentStatus.inUse: + return 'En prestation'; + case EquipmentStatus.maintenance: + return 'Maintenance'; + case EquipmentStatus.outOfService: + return 'Hors service'; + default: + return 'Autre'; + } + } + + Color _getStatusColor(EquipmentStatus status) { + switch (status) { + case EquipmentStatus.available: + return Colors.green; + case EquipmentStatus.inUse: + return Colors.orange; + case EquipmentStatus.maintenance: + return Colors.blue; + case EquipmentStatus.outOfService: + return Colors.red; + default: + return Colors.grey; + } + } + + String _getCategoryLabel(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.lighting: + return 'Lumière'; + case EquipmentCategory.sound: + return 'Son'; + case EquipmentCategory.video: + return 'Vidéo'; + case EquipmentCategory.effect: + return 'Effets'; + case EquipmentCategory.structure: + return 'Structure'; + case EquipmentCategory.consumable: + return 'Consommable'; + case EquipmentCategory.cable: + return 'Câble'; + case EquipmentCategory.other: + return 'Autre'; + } + } + + void _handleMenuAction(String action) { + if (action == 'delete') { + _deleteContainer(); + } + } + + void _editContainer() { + Navigator.pushNamed( + context, + '/container_form', + arguments: _container, + ).then((_) => _refreshContainer()); + } + + void _showQRCode() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('QR Code - ${_container.name}'), + content: SizedBox( + width: 250, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + QrImageView( + data: _container.id, + version: QrVersions.auto, + size: 200, + ), + const SizedBox(height: 16), + Text( + _container.id, + style: const TextStyle(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Fermer'), + ), + ], + ), + ); + } + + void _viewEquipment(EquipmentModel equipment) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => EquipmentDetailPage(equipment: equipment), + ), + ); + } + + Future _removeEquipment(EquipmentModel equipment) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Retirer l\'équipement'), + content: Text( + 'Êtes-vous sûr de vouloir retirer "${equipment.id}" de ce container ?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Retirer'), + ), + ], + ), + ); + + if (confirm == true && mounted) { + try { + await context.read().removeEquipmentFromContainer( + containerId: _container.id, + equipmentId: equipment.id, + ); + await _refreshContainer(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Équipement retiré avec succès')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e')), + ); + } + } + } + } + + Future _deleteContainer() async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Confirmer la suppression'), + content: Text( + 'Êtes-vous sûr de vouloir supprimer le container "${_container.name}" ?\n\n' + 'Cette action est irréversible.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Supprimer'), + ), + ], + ), + ); + + if (confirm == true && mounted) { + try { + await context.read().deleteContainer(_container.id); + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Container supprimé avec succès')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e')), + ); + } + } + } + } +} + diff --git a/em2rp/lib/views/container_form_page.dart b/em2rp/lib/views/container_form_page.dart new file mode 100644 index 0000000..0804556 --- /dev/null +++ b/em2rp/lib/views/container_form_page.dart @@ -0,0 +1,924 @@ +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/models/equipment_model.dart'; +import 'package:em2rp/providers/container_provider.dart'; +import 'package:em2rp/providers/equipment_provider.dart'; +import 'package:em2rp/utils/id_generator.dart'; + +class ContainerFormPage extends StatefulWidget { + final ContainerModel? container; + + const ContainerFormPage({super.key, this.container}); + + @override + State createState() => _ContainerFormPageState(); +} + +class _ContainerFormPageState extends State { + final _formKey = GlobalKey(); + + // Controllers + final _nameController = TextEditingController(); + final _idController = TextEditingController(); + final _weightController = TextEditingController(); + final _lengthController = TextEditingController(); + final _widthController = TextEditingController(); + final _heightController = TextEditingController(); + final _notesController = TextEditingController(); + + // Form fields + ContainerType _selectedType = ContainerType.flightCase; + EquipmentStatus _selectedStatus = EquipmentStatus.available; + bool _autoGenerateId = true; + final Set _selectedEquipmentIds = {}; + + bool _isEditing = false; + + @override + void initState() { + super.initState(); + if (widget.container != null) { + _isEditing = true; + _loadContainerData(); + } + } + + void _loadContainerData() { + final container = widget.container!; + _nameController.text = container.name; + _idController.text = container.id; + _selectedType = container.type; + _selectedStatus = container.status; + _weightController.text = container.weight?.toString() ?? ''; + _lengthController.text = container.length?.toString() ?? ''; + _widthController.text = container.width?.toString() ?? ''; + _heightController.text = container.height?.toString() ?? ''; + _notesController.text = container.notes ?? ''; + _selectedEquipmentIds.addAll(container.equipmentIds); + _autoGenerateId = false; + } + + void _updateIdFromName() { + if (_autoGenerateId && !_isEditing) { + final name = _nameController.text; + if (name.isNotEmpty) { + final baseId = IdGenerator.generateContainerId( + type: _selectedType, + name: name, + ); + _idController.text = baseId; + } + } + } + + void _updateIdFromType() { + if (_autoGenerateId && !_isEditing) { + final name = _nameController.text; + if (name.isNotEmpty) { + final baseId = IdGenerator.generateContainerId( + type: _selectedType, + name: name, + ); + _idController.text = baseId; + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_isEditing ? 'Modifier Container' : 'Nouveau Container'), + backgroundColor: AppColors.rouge, + foregroundColor: Colors.white, + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + + // Nom + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Nom du container *', + hintText: 'ex: Flight Case Beam 7R', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.label), + ), + onChanged: (_) => _updateIdFromName(), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer un nom'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // ID + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextFormField( + controller: _idController, + decoration: const InputDecoration( + labelText: 'Identifiant *', + hintText: 'ex: FLIGHTCASE_BEAM', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.qr_code), + ), + enabled: !_autoGenerateId || _isEditing, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer un identifiant'; + } + final validation = IdGenerator.validateContainerId(value); + return validation; + }, + ), + ), + if (!_isEditing) ...[ + const SizedBox(width: 8), + IconButton( + icon: Icon( + _autoGenerateId ? Icons.lock : Icons.lock_open, + color: _autoGenerateId ? AppColors.rouge : Colors.grey, + ), + tooltip: _autoGenerateId + ? 'Génération automatique' + : 'Saisie manuelle', + onPressed: () { + setState(() { + _autoGenerateId = !_autoGenerateId; + if (_autoGenerateId) { + _updateIdFromName(); + } + }); + }, + ), + ], + ], + ), + const SizedBox(height: 16), + + // Type + DropdownButtonFormField( + value: _selectedType, + decoration: const InputDecoration( + labelText: 'Type de container *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.category), + ), + items: ContainerType.values.map((type) { + return DropdownMenuItem( + value: type, + child: Text(containerTypeLabel(type)), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedType = value; + _updateIdFromType(); + }); + } + }, + ), + const SizedBox(height: 16), + + // Statut + DropdownButtonFormField( + value: _selectedStatus, + decoration: const InputDecoration( + labelText: 'Statut *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.info), + ), + items: [ + EquipmentStatus.available, + EquipmentStatus.inUse, + EquipmentStatus.maintenance, + EquipmentStatus.outOfService, + ].map((status) { + String label; + switch (status) { + case EquipmentStatus.available: + label = 'Disponible'; + break; + case EquipmentStatus.inUse: + label = 'En prestation'; + break; + case EquipmentStatus.maintenance: + label = 'En maintenance'; + break; + case EquipmentStatus.outOfService: + label = 'Hors service'; + break; + default: + label = 'Autre'; + } + return DropdownMenuItem( + value: status, + child: Text(label), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedStatus = value; + }); + } + }, + ), + const SizedBox(height: 24), + + // Section Caractéristiques physiques + Text( + 'Caractéristiques physiques', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Divider(), + const SizedBox(height: 16), + + // Poids + TextFormField( + controller: _weightController, + decoration: const InputDecoration( + labelText: 'Poids à vide (kg)', + hintText: 'ex: 15.5', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.scale), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value != null && value.isNotEmpty) { + if (double.tryParse(value) == null) { + return 'Veuillez entrer un nombre valide'; + } + } + return null; + }, + ), + const SizedBox(height: 16), + + // Dimensions + Row( + children: [ + Expanded( + child: TextFormField( + controller: _lengthController, + decoration: const InputDecoration( + labelText: 'Longueur (cm)', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value != null && value.isNotEmpty) { + if (double.tryParse(value) == null) { + return 'Nombre invalide'; + } + } + return null; + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _widthController, + decoration: const InputDecoration( + labelText: 'Largeur (cm)', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value != null && value.isNotEmpty) { + if (double.tryParse(value) == null) { + return 'Nombre invalide'; + } + } + return null; + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _heightController, + decoration: const InputDecoration( + labelText: 'Hauteur (cm)', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value != null && value.isNotEmpty) { + if (double.tryParse(value) == null) { + return 'Nombre invalide'; + } + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 24), + + // Section Équipements + Text( + 'Équipements dans ce container', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Divider(), + const SizedBox(height: 16), + + // Liste des équipements sélectionnés + if (_selectedEquipmentIds.isNotEmpty) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${_selectedEquipmentIds.length} équipement(s) sélectionné(s)', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: _selectedEquipmentIds.map((id) { + return Chip( + label: Text(id), + deleteIcon: const Icon(Icons.close, size: 18), + onDeleted: () { + setState(() { + _selectedEquipmentIds.remove(id); + }); + }, + ); + }).toList(), + ), + ], + ), + ) + else + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade50, + ), + child: const Center( + child: Text( + 'Aucun équipement sélectionné', + style: TextStyle(color: Colors.grey), + ), + ), + ), + const SizedBox(height: 12), + + // Bouton pour ajouter des équipements + OutlinedButton.icon( + onPressed: _selectEquipment, + icon: const Icon(Icons.add), + label: const Text('Ajouter des équipements'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + ), + ), + const SizedBox(height: 24), + + // Notes + TextFormField( + controller: _notesController, + decoration: const InputDecoration( + labelText: 'Notes', + hintText: 'Informations additionnelles...', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.notes), + ), + maxLines: 3, + ), + const SizedBox(height: 32), + + // Boutons + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + const SizedBox(width: 16), + ElevatedButton.icon( + onPressed: _saveContainer, + icon: const Icon(Icons.save, color: Colors.white), + label: Text( + _isEditing ? 'Mettre à jour' : 'Créer', + style: const TextStyle(color: Colors.white), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.rouge, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Future _selectEquipment() async { + final equipmentProvider = context.read(); + + await showDialog( + context: context, + builder: (context) => _EquipmentSelectorDialog( + selectedIds: _selectedEquipmentIds, + equipmentProvider: equipmentProvider, + ), + ); + + setState(() {}); + } + + Future _isIdUnique(String id) async { + final provider = context.read(); + final container = await provider.getContainerById(id); + return container == null; + } + + Future _saveContainer() async { + if (!_formKey.currentState!.validate()) { + return; + } + + try { + final containerProvider = context.read(); + + if (_isEditing) { + await _updateContainer(containerProvider); + } else { + await _createSingleContainer(containerProvider); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e')), + ); + } + } + } + + Future _createSingleContainer(ContainerProvider provider) async { + final baseId = _idController.text.trim(); + + // Vérifier l'unicité de l'ID directement + String uniqueId = baseId; + if (!await _isIdUnique(baseId)) { + uniqueId = '${baseId}_${DateTime.now().millisecondsSinceEpoch}'; + } + + final container = ContainerModel( + id: uniqueId, + name: _nameController.text.trim(), + type: _selectedType, + status: _selectedStatus, + equipmentIds: _selectedEquipmentIds.toList(), + weight: _weightController.text.isNotEmpty + ? double.tryParse(_weightController.text) + : null, + length: _lengthController.text.isNotEmpty + ? double.tryParse(_lengthController.text) + : null, + width: _widthController.text.isNotEmpty + ? double.tryParse(_widthController.text) + : null, + height: _heightController.text.isNotEmpty + ? double.tryParse(_heightController.text) + : null, + notes: _notesController.text.trim().isNotEmpty + ? _notesController.text.trim() + : null, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + await provider.createContainer(container); + + // Mettre à jour les parentBoxIds des équipements + for (final equipmentId in _selectedEquipmentIds) { + try { + await provider.addEquipmentToContainer( + containerId: uniqueId, + equipmentId: equipmentId, + ); + } catch (e) { + print('Erreur lors de l\'ajout de l\'équipement $equipmentId: $e'); + } + } + + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Container créé avec succès')), + ); + } + } + + Future _updateContainer(ContainerProvider provider) async { + final container = widget.container!; + + await provider.updateContainer(container.id, { + 'name': _nameController.text.trim(), + 'type': containerTypeToString(_selectedType), + 'status': equipmentStatusToString(_selectedStatus), + 'equipmentIds': _selectedEquipmentIds.toList(), + 'weight': _weightController.text.isNotEmpty + ? double.tryParse(_weightController.text) + : null, + 'length': _lengthController.text.isNotEmpty + ? double.tryParse(_lengthController.text) + : null, + 'width': _widthController.text.isNotEmpty + ? double.tryParse(_widthController.text) + : null, + 'height': _heightController.text.isNotEmpty + ? double.tryParse(_heightController.text) + : null, + 'notes': _notesController.text.trim().isNotEmpty + ? _notesController.text.trim() + : null, + }); + + // Gérer les équipements ajoutés + final addedEquipment = _selectedEquipmentIds.difference(container.equipmentIds.toSet()); + for (final equipmentId in addedEquipment) { + try { + await provider.addEquipmentToContainer( + containerId: container.id, + equipmentId: equipmentId, + ); + } catch (e) { + print('Erreur lors de l\'ajout de l\'équipement $equipmentId: $e'); + } + } + + // Gérer les équipements retirés + final removedEquipment = container.equipmentIds.toSet().difference(_selectedEquipmentIds); + for (final equipmentId in removedEquipment) { + try { + await provider.removeEquipmentFromContainer( + containerId: container.id, + equipmentId: equipmentId, + ); + } catch (e) { + print('Erreur lors du retrait de l\'équipement $equipmentId: $e'); + } + } + + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Container mis à jour avec succès')), + ); + } + } + + @override + void dispose() { + _nameController.dispose(); + _idController.dispose(); + _weightController.dispose(); + _lengthController.dispose(); + _widthController.dispose(); + _heightController.dispose(); + _notesController.dispose(); + super.dispose(); + } +} + +/// Widget de dialogue pour sélectionner les équipements +class _EquipmentSelectorDialog extends StatefulWidget { + final Set selectedIds; + final EquipmentProvider equipmentProvider; + + const _EquipmentSelectorDialog({ + required this.selectedIds, + required this.equipmentProvider, + }); + + @override + State<_EquipmentSelectorDialog> createState() => _EquipmentSelectorDialogState(); +} + +class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> { + final TextEditingController _searchController = TextEditingController(); + EquipmentCategory? _filterCategory; + String _searchQuery = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Container( + width: MediaQuery.of(context).size.width * 0.8, + height: MediaQuery.of(context).size.height * 0.8, + padding: const EdgeInsets.all(24), + child: Column( + children: [ + // En-tête + Row( + children: [ + const Icon(Icons.inventory, color: AppColors.rouge), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Sélectionner des équipements', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const Divider(), + const SizedBox(height: 16), + + // Barre de recherche + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Rechercher un équipement...', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + ) + : null, + ), + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + ), + const SizedBox(height: 16), + + // Filtres par catégorie + SizedBox( + height: 50, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + ChoiceChip( + label: const Text('Tout'), + selected: _filterCategory == null, + onSelected: (selected) { + setState(() { + _filterCategory = null; + }); + }, + selectedColor: AppColors.rouge, + labelStyle: TextStyle( + color: _filterCategory == null ? Colors.white : Colors.black, + ), + ), + const SizedBox(width: 8), + ...EquipmentCategory.values.map((category) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(_getCategoryLabel(category)), + selected: _filterCategory == category, + onSelected: (selected) { + setState(() { + _filterCategory = selected ? category : null; + }); + }, + selectedColor: AppColors.rouge, + labelStyle: TextStyle( + color: _filterCategory == category ? Colors.white : Colors.black, + ), + ), + ); + }), + ], + ), + ), + const SizedBox(height: 16), + + // Compteur de sélection + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.rouge.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.check_circle, color: AppColors.rouge), + const SizedBox(width: 8), + Text( + '${widget.selectedIds.length} équipement(s) sélectionné(s)', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Liste des équipements + Expanded( + child: StreamBuilder>( + stream: widget.equipmentProvider.equipmentStream, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center(child: Text('Erreur: ${snapshot.error}')); + } + + var equipment = snapshot.data ?? []; + + // Filtrer par catégorie + if (_filterCategory != null) { + equipment = equipment.where((e) => e.category == _filterCategory).toList(); + } + + // Filtrer par recherche + if (_searchQuery.isNotEmpty) { + final query = _searchQuery.toLowerCase(); + equipment = equipment.where((e) { + return e.id.toLowerCase().contains(query) || + (e.brand?.toLowerCase().contains(query) ?? false) || + (e.model?.toLowerCase().contains(query) ?? false); + }).toList(); + } + + if (equipment.isEmpty) { + return const Center( + child: Text('Aucun équipement trouvé'), + ); + } + + return ListView.builder( + itemCount: equipment.length, + itemBuilder: (context, index) { + final item = equipment[index]; + final isSelected = widget.selectedIds.contains(item.id); + + return CheckboxListTile( + value: isSelected, + onChanged: (selected) { + setState(() { + if (selected == true) { + widget.selectedIds.add(item.id); + } else { + widget.selectedIds.remove(item.id); + } + }); + }, + title: Text( + item.id, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (item.brand != null || item.model != null) + Text('${item.brand ?? ''} ${item.model ?? ''}'), + const SizedBox(height: 4), + Text( + _getCategoryLabel(item.category), + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + secondary: Icon( + _getCategoryIcon(item.category), + color: AppColors.rouge, + ), + activeColor: AppColors.rouge, + ); + }, + ); + }, + ), + ), + + // Boutons d'action + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.rouge, + ), + child: const Text( + 'Valider', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), + ], + ), + ), + ); + } + + String _getCategoryLabel(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.lighting: + return 'Lumière'; + case EquipmentCategory.sound: + return 'Son'; + case EquipmentCategory.video: + return 'Vidéo'; + case EquipmentCategory.effect: + return 'Effets'; + case EquipmentCategory.structure: + return 'Structure'; + case EquipmentCategory.consumable: + return 'Consommable'; + case EquipmentCategory.cable: + return 'Câble'; + case EquipmentCategory.other: + return 'Autre'; + } + } + + IconData _getCategoryIcon(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.lighting: + return Icons.lightbulb; + case EquipmentCategory.sound: + return Icons.speaker; + case EquipmentCategory.video: + return Icons.videocam; + case EquipmentCategory.effect: + return Icons.auto_awesome; + case EquipmentCategory.structure: + return Icons.construction; + case EquipmentCategory.consumable: + return Icons.inventory; + case EquipmentCategory.cable: + return Icons.cable; + case EquipmentCategory.other: + return Icons.category; + } + } +} + diff --git a/em2rp/lib/views/container_management_page.dart b/em2rp/lib/views/container_management_page.dart new file mode 100644 index 0000000..0ab6cf2 --- /dev/null +++ b/em2rp/lib/views/container_management_page.dart @@ -0,0 +1,814 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/utils/permission_gate.dart'; +import 'package:em2rp/views/widgets/nav/main_drawer.dart'; +import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; +import 'package:em2rp/providers/container_provider.dart'; +import 'package:em2rp/models/container_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/services/container_pdf_generator_service.dart'; +import 'package:em2rp/views/widgets/common/qr_code_dialog.dart'; +import 'package:em2rp/mixins/selection_mode_mixin.dart'; +import 'package:printing/printing.dart'; +import 'package:pdf/pdf.dart'; + +class ContainerManagementPage extends StatefulWidget { + const ContainerManagementPage({super.key}); + + @override + State createState() => + _ContainerManagementPageState(); +} + +class _ContainerManagementPageState extends State + with SelectionModeMixin { + final TextEditingController _searchController = TextEditingController(); + ContainerType? _selectedType; + EquipmentStatus? _selectedStatus; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isMobile = MediaQuery.of(context).size.width < 800; + + return PermissionGate( + requiredPermissions: const ['view_equipment'], + fallback: Scaffold( + appBar: const CustomAppBar(title: 'Accès refusé'), + drawer: const MainDrawer(currentPage: '/container_management'), + body: const Center( + child: Padding( + padding: EdgeInsets.all(24.0), + child: Text( + 'Vous n\'avez pas les permissions nécessaires pour accéder à la gestion des containers.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16), + ), + ), + ), + ), + child: Scaffold( + appBar: isSelectionMode + ? AppBar( + backgroundColor: AppColors.rouge, + leading: IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: toggleSelectionMode, + ), + title: Text( + '$selectedCount sélectionné(s)', + style: const TextStyle(color: Colors.white), + ), + actions: [ + if (hasSelection) ...[ + IconButton( + icon: const Icon(Icons.qr_code, color: Colors.white), + tooltip: 'Générer QR Codes', + onPressed: _generateQRCodesForSelected, + ), + IconButton( + icon: const Icon(Icons.delete, color: Colors.white), + tooltip: 'Supprimer', + onPressed: _deleteSelectedContainers, + ), + ], + ], + ) + : const CustomAppBar(title: 'Gestion des Containers'), + drawer: const MainDrawer(currentPage: '/container_management'), + floatingActionButton: !isSelectionMode + ? FloatingActionButton.extended( + onPressed: () => _navigateToForm(context), + backgroundColor: AppColors.rouge, + icon: const Icon(Icons.add, color: Colors.white), + label: const Text( + 'Nouveau Container', + style: TextStyle(color: Colors.white), + ), + ) + : null, + body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(), + ), + ); + } + + Widget _buildMobileLayout() { + return Column( + children: [ + _buildSearchBar(), + _buildMobileFilters(), + Expanded(child: _buildContainerList()), + ], + ); + } + + Widget _buildDesktopLayout() { + return Row( + children: [ + SizedBox( + width: 250, + child: _buildSidebar(), + ), + const VerticalDivider(width: 1, thickness: 1), + Expanded( + child: Column( + children: [ + _buildSearchBar(), + Expanded(child: _buildContainerList()), + ], + ), + ), + ], + ); + } + + Widget _buildSearchBar() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Rechercher un container...', + prefixIcon: const Icon(Icons.search, color: AppColors.rouge), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onChanged: (value) { + context.read().setSearchQuery(value); + }, + ), + ), + const SizedBox(width: 12), + if (!isSelectionMode) + IconButton( + icon: const Icon(Icons.checklist, color: AppColors.rouge), + tooltip: 'Mode sélection', + onPressed: toggleSelectionMode, + ), + ], + ), + ); + } + + Widget _buildMobileFilters() { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + color: Colors.grey.shade50, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + _buildTypeChip(null, 'Tous'), + const SizedBox(width: 8), + ...ContainerType.values.map((type) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: _buildTypeChip(type, containerTypeLabel(type)), + ); + }), + ], + ), + ), + ); + } + + Widget _buildTypeChip(ContainerType? type, String label) { + final isSelected = _selectedType == type; + return ChoiceChip( + label: Text(label), + selected: isSelected, + onSelected: (selected) { + setState(() { + _selectedType = selected ? type : null; + context.read().setSelectedType(_selectedType); + }); + }, + selectedColor: AppColors.rouge, + labelStyle: TextStyle( + color: isSelected ? Colors.white : AppColors.noir, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ); + } + + Widget _buildSidebar() { + return Container( + color: Colors.grey.shade50, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'Filtres', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.noir, + ), + ), + const SizedBox(height: 16), + + // Filtre par type + Text( + 'Type de container', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.noir, + ), + ), + const SizedBox(height: 8), + _buildFilterOption(null, 'Tous les types'), + ...ContainerType.values.map((type) { + return _buildFilterOption(type, containerTypeLabel(type)); + }), + + const Divider(height: 32), + + // Filtre par statut + Text( + 'Statut', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.noir, + ), + ), + const SizedBox(height: 8), + _buildStatusFilter(null, 'Tous les statuts'), + _buildStatusFilter(EquipmentStatus.available, 'Disponible'), + _buildStatusFilter(EquipmentStatus.inUse, 'En prestation'), + _buildStatusFilter(EquipmentStatus.maintenance, 'En maintenance'), + _buildStatusFilter(EquipmentStatus.outOfService, 'Hors service'), + ], + ), + ); + } + + Widget _buildFilterOption(ContainerType? type, String label) { + final isSelected = _selectedType == type; + return RadioListTile( + title: Text(label), + value: type, + groupValue: _selectedType, + activeColor: AppColors.rouge, + dense: true, + contentPadding: EdgeInsets.zero, + onChanged: (value) { + setState(() { + _selectedType = value; + context.read().setSelectedType(_selectedType); + }); + }, + ); + } + + Widget _buildStatusFilter(EquipmentStatus? status, String label) { + final isSelected = _selectedStatus == status; + return RadioListTile( + title: Text(label), + value: status, + groupValue: _selectedStatus, + activeColor: AppColors.rouge, + dense: true, + contentPadding: EdgeInsets.zero, + onChanged: (value) { + setState(() { + _selectedStatus = value; + context.read().setSelectedStatus(_selectedStatus); + }); + }, + ); + } + + Widget _buildContainerList() { + return Consumer( + builder: (context, provider, child) { + return StreamBuilder>( + stream: provider.containersStream, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Text('Erreur: ${snapshot.error}'), + ); + } + + final containers = snapshot.data ?? []; + + if (containers.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.inventory_2_outlined, + size: 80, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + 'Aucun container trouvé', + style: TextStyle( + fontSize: 18, + color: Colors.grey.shade600, + ), + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: containers.length, + itemBuilder: (context, index) { + final container = containers[index]; + return _buildContainerCard(container); + }, + ); + }, + ); + }, + ); + } + + Widget _buildContainerCard(ContainerModel container) { + final isSelected = isItemSelected(container.id); + + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: isSelected + ? const BorderSide(color: AppColors.rouge, width: 2) + : BorderSide.none, + ), + child: InkWell( + onTap: () { + if (isSelectionMode) { + toggleItemSelection(container.id); + } else { + _viewContainerDetails(container); + } + }, + onLongPress: () { + if (!isSelectionMode) { + toggleSelectionMode(); + toggleItemSelection(container.id); + } + }, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + if (isSelectionMode) + Padding( + padding: const EdgeInsets.only(right: 16), + child: Checkbox( + value: isSelected, + onChanged: (value) { + toggleItemSelection(container.id); + }, + activeColor: AppColors.rouge, + ), + ), + + // Icône du type de container + Icon( + _getTypeIcon(container.type), + size: 40, + color: AppColors.rouge, + ), + + const SizedBox(width: 16), + + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + container.id, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + container.name, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade700, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + _buildInfoChip( + containerTypeLabel(container.type), + Icons.category, + ), + const SizedBox(width: 8), + _buildInfoChip( + '${container.itemCount} items', + Icons.inventory, + ), + ], + ), + ], + ), + ), + + const SizedBox(width: 16), + + // Badge de statut + _buildStatusBadge(container.status), + + if (!isSelectionMode) ...[ + const SizedBox(width: 8), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) => _handleMenuAction(value, container), + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'view', + child: Row( + children: [ + Icon(Icons.visibility, size: 20), + SizedBox(width: 8), + Text('Voir détails'), + ], + ), + ), + const PopupMenuItem( + value: 'edit', + child: Row( + children: [ + Icon(Icons.edit, size: 20), + SizedBox(width: 8), + Text('Modifier'), + ], + ), + ), + const PopupMenuItem( + value: 'qr', + child: Row( + children: [ + Icon(Icons.qr_code, size: 20), + SizedBox(width: 8), + Text('QR Code'), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete, color: Colors.red, size: 20), + SizedBox(width: 8), + Text('Supprimer', style: TextStyle(color: Colors.red)), + ], + ), + ), + ], + ), + ], + ], + ), + ), + ), + ); + } + + Widget _buildInfoChip(String label, IconData icon) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: Colors.grey.shade700), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + ), + ), + ], + ), + ); + } + + Widget _buildStatusBadge(EquipmentStatus status) { + Color color; + String label; + + switch (status) { + case EquipmentStatus.available: + color = Colors.green; + label = 'Disponible'; + break; + case EquipmentStatus.inUse: + color = Colors.orange; + label = 'En prestation'; + break; + case EquipmentStatus.maintenance: + color = Colors.blue; + label = 'Maintenance'; + break; + case EquipmentStatus.outOfService: + color = Colors.red; + label = 'Hors service'; + break; + default: + color = Colors.grey; + label = 'Autre'; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: color), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ); + } + + 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; + } + } + + + void _handleMenuAction(String action, ContainerModel container) { + switch (action) { + case 'view': + _viewContainerDetails(container); + break; + case 'edit': + _editContainer(container); + break; + case 'qr': + showDialog( + context: context, + builder: (context) => QRCodeDialog.forContainer(container), + ); + break; + case 'delete': + _deleteContainer(container); + break; + } + } + + void _navigateToForm(BuildContext context) async { + final result = await Navigator.pushNamed(context, '/container_form'); + if (result == true) { + // Rafraîchir la liste + } + } + + void _viewContainerDetails(ContainerModel container) async { + await Navigator.pushNamed( + context, + '/container_detail', + arguments: container, + ); + } + + void _editContainer(ContainerModel container) async { + await Navigator.pushNamed( + context, + '/container_form', + arguments: container, + ); + } + + + Future _generateQRCodesForSelected() async { + if (!hasSelection) return; + + // Récupérer les containers sélectionnés + final containerProvider = context.read(); + final List selectedContainers = []; + final Map> containerEquipmentMap = {}; + + for (final id in selectedIds) { + final container = await containerProvider.getContainerById(id); + if (container != null) { + selectedContainers.add(container); + // Charger les équipements pour ce container + final equipment = await containerProvider.getContainerEquipment(id); + containerEquipmentMap[id] = equipment; + } + } + + if (selectedContainers.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Aucun container trouvé')), + ); + } + return; + } + + // Afficher le dialogue de sélection de format + final format = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Format des étiquettes'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.qr_code_2), + title: const Text('Petits QR codes'), + subtitle: const Text('2×2 cm - QR code + ID (20 par page)'), + onTap: () => Navigator.pop(context, ContainerQRLabelFormat.small), + ), + ListTile( + leading: const Icon(Icons.qr_code), + title: const Text('QR codes moyens'), + subtitle: const Text('4×4 cm - QR code + ID (6 par page)'), + onTap: () => Navigator.pop(context, ContainerQRLabelFormat.medium), + ), + ListTile( + leading: const Icon(Icons.label), + title: const Text('Grandes étiquettes'), + subtitle: const Text('QR code + ID + Type + Contenu (6 par page)'), + onTap: () => Navigator.pop(context, ContainerQRLabelFormat.large), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ], + ), + ); + + if (format == null || !mounted) return; + + // Générer et afficher le PDF + try { + final pdfBytes = await ContainerPDFGeneratorService.generateQRCodesPDF( + containerList: selectedContainers, + containerEquipmentMap: containerEquipmentMap, + format: format, + ); + + if (mounted) { + await Printing.layoutPdf( + onLayout: (PdfPageFormat format) async => pdfBytes, + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur lors de la génération: $e')), + ); + } + } + } + + Future _deleteContainer(ContainerModel container) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Confirmer la suppression'), + content: Text( + 'Êtes-vous sûr de vouloir supprimer le container "${container.name}" ?\n\n' + 'Cette action est irréversible.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Supprimer'), + ), + ], + ), + ); + + if (confirm == true && mounted) { + try { + await context.read().deleteContainer(container.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Container supprimé avec succès')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur lors de la suppression: $e')), + ); + } + } + } + } + + Future _deleteSelectedContainers() async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Confirmer la suppression'), + content: Text( + 'Êtes-vous sûr de vouloir supprimer $selectedCount container(s) ?\n\n' + 'Cette action est irréversible.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Supprimer'), + ), + ], + ), + ); + + if (confirm == true && mounted) { + try { + final provider = context.read(); + for (final id in selectedIds) { + await provider.deleteContainer(id); + } + disableSelectionMode(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Containers supprimés avec succès')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur lors de la suppression: $e')), + ); + } + } + } + } +} + diff --git a/em2rp/lib/views/equipment_detail_page.dart b/em2rp/lib/views/equipment_detail_page.dart new file mode 100644 index 0000000..e625a36 --- /dev/null +++ b/em2rp/lib/views/equipment_detail_page.dart @@ -0,0 +1,881 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/models/maintenance_model.dart'; +import 'package:em2rp/providers/equipment_provider.dart'; +import 'package:em2rp/providers/local_user_provider.dart'; +import 'package:em2rp/services/equipment_service.dart'; +import 'package:em2rp/services/qr_code_service.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/utils/permission_gate.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:qr_flutter/qr_flutter.dart'; +import 'package:printing/printing.dart'; + +class EquipmentDetailPage extends StatefulWidget { + final EquipmentModel equipment; + + const EquipmentDetailPage({super.key, required this.equipment}); + + @override + State createState() => _EquipmentDetailPageState(); +} + +class _EquipmentDetailPageState extends State { + final EquipmentService _equipmentService = EquipmentService(); + List _maintenances = []; + bool _isLoadingMaintenances = true; + + @override + void initState() { + super.initState(); + _loadMaintenances(); + } + + Future _loadMaintenances() async { + try { + final maintenances = await _equipmentService.getMaintenancesForEquipment(widget.equipment.id); + setState(() { + _maintenances = maintenances; + _isLoadingMaintenances = false; + }); + } catch (e) { + setState(() { + _isLoadingMaintenances = false; + }); + } + } + + + @override + Widget build(BuildContext context) { + final isMobile = MediaQuery.of(context).size.width < 800; + final userProvider = Provider.of(context); + final hasManagePermission = userProvider.hasPermission('manage_equipment'); + + return Scaffold( + appBar: CustomAppBar( + title: widget.equipment.id, + actions: [ + IconButton( + icon: const Icon(Icons.qr_code), + tooltip: 'Générer QR Code', + onPressed: _showQRCode, + ), + if (hasManagePermission) + IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Modifier', + onPressed: _editEquipment, + ), + if (hasManagePermission) + IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + tooltip: 'Supprimer', + onPressed: _deleteEquipment, + ), + ], + ), + body: SingleChildScrollView( + padding: EdgeInsets.all(isMobile ? 16 : 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + const SizedBox(height: 24), + _buildMainInfoSection(), + const SizedBox(height: 24), + if (hasManagePermission) ...[ + _buildPriceSection(), + const SizedBox(height: 24), + ], + if (widget.equipment.category == EquipmentCategory.consumable || + widget.equipment.category == EquipmentCategory.cable) ...[ + _buildQuantitySection(), + const SizedBox(height: 24), + ], + 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(), + 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: Icon( + _getCategoryIcon(widget.equipment.category), + color: AppColors.rouge, + size: 32, + ), + ), + 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 statusInfo = _getStatusInfo(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: statusInfo.$2, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text( + statusInfo.$1, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: statusInfo.$2, + ), + ), + ], + ), + ); + } + + 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', _getCategoryName(widget.equipment.category)), + 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', _getStatusInfo(widget.equipment.status).$1), + ], + ), + ), + ); + } + + 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', + ), + ], + ), + ), + ); + } + + 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()), + ], + ), + ), + ); + } + + + 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, + builder: (context) => Dialog( + child: Container( + padding: const EdgeInsets.all(24), + constraints: const BoxConstraints(maxWidth: 500), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Icon(Icons.qr_code, color: AppColors.rouge, size: 32), + const SizedBox(width: 12), + Expanded( + child: Text( + 'QR Code - ${widget.equipment.id}', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: QrImageView( + data: widget.equipment.id, + version: QrVersions.auto, + size: 300, + backgroundColor: Colors.white, + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.equipment.id, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + '${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(), + style: TextStyle(color: Colors.grey[700]), + ), + ], + ), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => _exportQRCode(), + icon: const Icon(Icons.download), + label: const Text('Télécharger PNG'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 48), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: () { + Navigator.pop(context); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.rouge, + minimumSize: const Size(0, 48), + ), + icon: const Icon(Icons.close, color: Colors.white), + label: const Text( + 'Fermer', + style: TextStyle(color: Colors.white), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Future _exportQRCode() async { + try { + final qrImage = await QRCodeService.generateQRCode( + widget.equipment.id, + size: 1024, + useCache: false, + ); + + await Printing.sharePdf( + bytes: qrImage, + filename: 'QRCode_${widget.equipment.id}.png', + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('QR Code exporté avec succès'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur lors de l\'export: $e')), + ); + } + } + } + + void _editEquipment() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => EquipmentFormPage(equipment: widget.equipment), + ), + ).then((_) { + Navigator.pop(context); + }); + } + + void _deleteEquipment() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Confirmer la suppression'), + content: Text( + 'Voulez-vous vraiment supprimer "${widget.equipment.id}" ?\n\nCette action est irréversible.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () async { + Navigator.pop(context); + try { + await context + .read() + .deleteEquipment(widget.equipment.id); + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Équipement supprimé avec succès'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e')), + ); + } + } + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Supprimer'), + ), + ], + ), + ); + } + + IconData _getCategoryIcon(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.lighting: + return Icons.light_mode; + case EquipmentCategory.sound: + return Icons.volume_up; + case EquipmentCategory.video: + return Icons.videocam; + case EquipmentCategory.effect: + return Icons.auto_awesome; + case EquipmentCategory.structure: + return Icons.construction; + case EquipmentCategory.consumable: + return Icons.inventory_2; + case EquipmentCategory.cable: + return Icons.cable; + case EquipmentCategory.other: + return Icons.more_horiz; + } + } + + String _getCategoryName(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.lighting: + return 'Lumière'; + case EquipmentCategory.sound: + return 'Son'; + case EquipmentCategory.video: + return 'Vidéo'; + case EquipmentCategory.effect: + return 'Effets'; + case EquipmentCategory.structure: + return 'Structure'; + case EquipmentCategory.consumable: + return 'Consommable'; + case EquipmentCategory.cable: + return 'Câble'; + case EquipmentCategory.other: + return 'Autre'; + } + } + + (String, Color) _getStatusInfo(EquipmentStatus status) { + switch (status) { + case EquipmentStatus.available: + return ('Disponible', Colors.green); + case EquipmentStatus.inUse: + return ('En prestation', Colors.blue); + case EquipmentStatus.rented: + return ('Loué', Colors.orange); + case EquipmentStatus.lost: + return ('Perdu', Colors.red); + case EquipmentStatus.outOfService: + return ('HS', Colors.red[900]!); + case EquipmentStatus.maintenance: + return ('Maintenance', Colors.amber); + } + } + + (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); + } + } +} + diff --git a/em2rp/lib/views/equipment_form/id_generator.dart b/em2rp/lib/views/equipment_form/id_generator.dart deleted file mode 100644 index 7fb22dd..0000000 --- a/em2rp/lib/views/equipment_form/id_generator.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:em2rp/services/equipment_service.dart'; - -class EquipmentIdGenerator { - static String generate({required String brand, required String model, int? number}) { - final brandTrim = brand.trim().replaceAll(' ', '_'); - final modelTrim = model.trim().replaceAll(' ', '_'); - if (brandTrim.isEmpty && modelTrim.isEmpty) { - return 'EQ-${DateTime.now().millisecondsSinceEpoch}${number != null ? '_$number' : ''}'; - } - final brandPrefix = brandTrim.length >= 4 ? brandTrim.substring(0, 4) : brandTrim; - String baseId = modelTrim.isNotEmpty ? '${brandPrefix}_$modelTrim' : (brandPrefix.isNotEmpty ? brandPrefix : 'EQ'); - if (number != null) { - baseId += '_#$number'; - } - return baseId; - } - - static Future ensureUniqueId(String baseId, EquipmentService service) async { - if (await service.isIdUnique(baseId)) { - return baseId; - } - return '${baseId}_${DateTime.now().millisecondsSinceEpoch}'; - } -} - diff --git a/em2rp/lib/views/equipment_form_page.dart b/em2rp/lib/views/equipment_form_page.dart index bc88fac..3a1d53a 100644 --- a/em2rp/lib/views/equipment_form_page.dart +++ b/em2rp/lib/views/equipment_form_page.dart @@ -9,7 +9,7 @@ import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:intl/intl.dart'; import 'package:em2rp/views/equipment_form/brand_model_selector.dart'; -import 'package:em2rp/views/equipment_form/id_generator.dart'; +import 'package:em2rp/utils/id_generator.dart'; class EquipmentFormPage extends StatefulWidget { final EquipmentModel? equipment; @@ -165,6 +165,15 @@ class _EquipmentFormPageState extends State { helperText: isEditing ? 'Non modifiable' : 'Format auto: {Marque4Chars}_{Modèle}', ), enabled: !isEditing, + validator: (value) { + 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 null; + }, ), const SizedBox(height: 16), @@ -585,13 +594,13 @@ class _EquipmentFormPageState extends State { // Générer les IDs if (numbers.isEmpty) { - String baseId = EquipmentIdGenerator.generate(brand: brand, model: model, number: null); - String uniqueId = await EquipmentIdGenerator.ensureUniqueId(baseId, _equipmentService); + String baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: null); + String uniqueId = await IdGenerator.ensureUniqueEquipmentId(baseId, _equipmentService); ids.add(uniqueId); } else { for (final num in numbers) { - String baseId = EquipmentIdGenerator.generate(brand: brand, model: model, number: num); - String uniqueId = await EquipmentIdGenerator.ensureUniqueId(baseId, _equipmentService); + String baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: num); + String uniqueId = await IdGenerator.ensureUniqueEquipmentId(baseId, _equipmentService); ids.add(uniqueId); } } diff --git a/em2rp/lib/views/equipment_management_page.dart b/em2rp/lib/views/equipment_management_page.dart index 430c4c4..72f25ed 100644 --- a/em2rp/lib/views/equipment_management_page.dart +++ b/em2rp/lib/views/equipment_management_page.dart @@ -1,4 +1,3 @@ -import 'package:firebase_storage/firebase_storage.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:em2rp/utils/colors.dart'; @@ -8,46 +7,24 @@ import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:em2rp/providers/equipment_provider.dart'; import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/views/equipment_form_page.dart'; -import 'package:qr_flutter/qr_flutter.dart'; -import 'package:pdf/pdf.dart'; -import 'package:pdf/widgets.dart' as pw; -import 'package:printing/printing.dart'; -import 'dart:typed_data'; -import 'dart:ui' as ui; +import 'package:em2rp/views/equipment_detail_page.dart'; +import 'package:em2rp/views/widgets/common/qr_code_dialog.dart'; +import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart'; +import 'package:em2rp/mixins/selection_mode_mixin.dart'; class EquipmentManagementPage extends StatefulWidget { const EquipmentManagementPage({super.key}); @override - State createState() => _EquipmentManagementPageState(); + State createState() => + _EquipmentManagementPageState(); } -enum QRLabelFormat { small, medium, large } -class _EquipmentManagementPageState extends State { +class _EquipmentManagementPageState extends State + with SelectionModeMixin { final TextEditingController _searchController = TextEditingController(); EquipmentCategory? _selectedCategory; - bool _isSelectionMode = false; - final Set _selectedEquipmentIds = {}; - - void _toggleSelectionMode() { - setState(() { - _isSelectionMode = !_isSelectionMode; - if (!_isSelectionMode) { - _selectedEquipmentIds.clear(); - } - }); - } - - void _toggleEquipmentSelection(String id) { - setState(() { - if (_selectedEquipmentIds.contains(id)) { - _selectedEquipmentIds.remove(id); - } else { - _selectedEquipmentIds.add(id); - } - }); - } @override void dispose() { @@ -76,19 +53,19 @@ class _EquipmentManagementPageState extends State { ), ), child: Scaffold( - appBar: _isSelectionMode + appBar: isSelectionMode ? AppBar( backgroundColor: AppColors.rouge, leading: IconButton( icon: const Icon(Icons.close, color: Colors.white), - onPressed: _toggleSelectionMode, + onPressed: toggleSelectionMode, ), title: Text( - '${_selectedEquipmentIds.length} sélectionné(s)', + '$selectedCount sélectionné(s)', style: const TextStyle(color: Colors.white), ), actions: [ - if (_selectedEquipmentIds.isNotEmpty) ...[ + if (hasSelection) ...[ IconButton( icon: const Icon(Icons.qr_code, color: Colors.white), tooltip: 'Générer QR Codes', @@ -108,13 +85,13 @@ class _EquipmentManagementPageState extends State { IconButton( icon: const Icon(Icons.checklist), tooltip: 'Mode sélection', - onPressed: _toggleSelectionMode, + onPressed: toggleSelectionMode, ), ], ), drawer: const MainDrawer(currentPage: '/equipment_management'), body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(), - floatingActionButton: _isSelectionMode ? null : _buildFAB(), + floatingActionButton: isSelectionMode ? null : _buildFAB(), ), ); } @@ -134,30 +111,49 @@ class _EquipmentManagementPageState extends State { Widget _buildMobileLayout() { return Column( children: [ - // Barre de recherche + // Barre de recherche et bouton boîtes Padding( padding: const EdgeInsets.all(16.0), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Rechercher par nom, modèle ou ID...', - prefixIcon: const Icon(Icons.search), - suffixIcon: _searchController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - context.read().setSearchQuery(''); - }, - ) - : null, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Rechercher par nom, modèle ou ID...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + context.read().setSearchQuery(''); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onChanged: (value) { + context.read().setSearchQuery(value); + }, + ), ), - ), - onChanged: (value) { - context.read().setSearchQuery(value); - }, + const SizedBox(width: 8), + // Bouton Gérer les boîtes + IconButton.filled( + onPressed: () { + Navigator.pushNamed(context, '/container_management'); + }, + icon: const Icon(Icons.inventory_2), + tooltip: 'Gérer les boîtes', + style: IconButton.styleFrom( + backgroundColor: AppColors.rouge, + foregroundColor: Colors.white, + ), + ), + ], ), ), // Menu horizontal de filtres par catégorie @@ -182,13 +178,19 @@ class _EquipmentManagementPageState extends State { onSelected: (selected) { if (selected) { setState(() => _selectedCategory = null); - context.read().setSelectedCategory(null); + context + .read() + .setSelectedCategory(null); } }, selectedColor: AppColors.rouge, labelStyle: TextStyle( - color: _selectedCategory == null ? Colors.white : AppColors.rouge, - fontWeight: _selectedCategory == null ? FontWeight.bold : FontWeight.normal, + color: _selectedCategory == null + ? Colors.white + : AppColors.rouge, + fontWeight: _selectedCategory == null + ? FontWeight.bold + : FontWeight.normal, ), ), ), @@ -217,23 +219,44 @@ class _EquipmentManagementPageState extends State { ), child: Column( children: [ - // En-tête - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.rouge.withOpacity(0.1), + // Bouton Gérer les boîtes + Padding( + padding: const EdgeInsets.all(16.0), + child: ElevatedButton.icon( + onPressed: () { + Navigator.pushNamed(context, '/container_management'); + }, + icon: const Icon(Icons.inventory_2, color: Colors.white), + label: const Text( + 'Gérer les boîtes', + style: TextStyle(color: Colors.white), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.rouge, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + minimumSize: const Size(double.infinity, 50), + ), ), + ), + const Divider(), + // En-tête filtres + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Row( children: [ - Icon(Icons.inventory, color: AppColors.rouge), + Icon(Icons.filter_list, color: AppColors.rouge, size: 20), const SizedBox(width: 12), Expanded( child: Text( 'Filtres', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: AppColors.rouge, - ), + style: + Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.rouge, + ), ), ), ], @@ -252,7 +275,9 @@ class _EquipmentManagementPageState extends State { icon: const Icon(Icons.clear, size: 20), onPressed: () { _searchController.clear(); - context.read().setSearchQuery(''); + context + .read() + .setSearchQuery(''); }, ) : null, @@ -260,7 +285,8 @@ class _EquipmentManagementPageState extends State { borderRadius: BorderRadius.circular(8), ), isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), onChanged: (value) { context.read().setSearchQuery(value); @@ -270,7 +296,8 @@ class _EquipmentManagementPageState extends State { const Divider(), // Filtres par catégorie Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), child: Align( alignment: Alignment.centerLeft, child: Text( @@ -288,20 +315,28 @@ class _EquipmentManagementPageState extends State { ListTile( leading: Icon( Icons.all_inclusive, - color: _selectedCategory == null ? AppColors.rouge : Colors.grey[600], + color: _selectedCategory == null + ? AppColors.rouge + : Colors.grey[600], ), title: Text( 'Tout', style: TextStyle( - color: _selectedCategory == null ? AppColors.rouge : Colors.black87, - fontWeight: _selectedCategory == null ? FontWeight.bold : FontWeight.normal, + color: _selectedCategory == null + ? AppColors.rouge + : Colors.black87, + fontWeight: _selectedCategory == null + ? FontWeight.bold + : FontWeight.normal, ), ), selected: _selectedCategory == null, selectedTileColor: AppColors.rouge.withOpacity(0.1), onTap: () { setState(() => _selectedCategory = null); - context.read().setSelectedCategory(null); + context + .read() + .setSelectedCategory(null); }, ), ..._buildCategoryListTiles(), @@ -420,7 +455,8 @@ class _EquipmentManagementPageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.inventory_2_outlined, size: 64, color: Colors.grey[400]), + Icon(Icons.inventory_2_outlined, + size: 64, color: Colors.grey[400]), const SizedBox(height: 16), Text( 'Aucun équipement trouvé', @@ -454,20 +490,23 @@ class _EquipmentManagementPageState extends State { } Widget _buildEquipmentCard(EquipmentModel equipment) { - final isSelected = _selectedEquipmentIds.contains(equipment.id); + final isSelected = isItemSelected(equipment.id); return Card( margin: const EdgeInsets.only(bottom: 12), - color: _isSelectionMode && isSelected ? AppColors.rouge.withOpacity(0.1) : null, + color: isSelectionMode && isSelected + ? AppColors.rouge.withOpacity(0.1) + : null, child: ListTile( - leading: _isSelectionMode + leading: isSelectionMode ? Checkbox( value: isSelected, - onChanged: (value) => _toggleEquipmentSelection(equipment.id), + onChanged: (value) => toggleItemSelection(equipment.id), activeColor: AppColors.rouge, ) : CircleAvatar( - backgroundColor: _getStatusColor(equipment.status).withOpacity(0.2), + backgroundColor: + _getStatusColor(equipment.status).withOpacity(0.2), child: Icon( _getCategoryIcon(equipment.category), color: _getStatusColor(equipment.status), @@ -492,7 +531,9 @@ class _EquipmentManagementPageState extends State { children: [ const SizedBox(height: 4), Text( - '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim().isNotEmpty + '${equipment.brand ?? ''} ${equipment.model ?? ''}' + .trim() + .isNotEmpty ? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim() : 'Marque/Modèle non défini', style: TextStyle(color: Colors.grey[600], fontSize: 14), @@ -505,7 +546,7 @@ class _EquipmentManagementPageState extends State { ], ], ), - trailing: _isSelectionMode + trailing: isSelectionMode ? null : Row( mainAxisSize: MainAxisSize.min, @@ -516,7 +557,8 @@ class _EquipmentManagementPageState extends State { PermissionGate( requiredPermissions: const ['manage_equipment'], child: IconButton( - icon: const Icon(Icons.add_shopping_cart, color: AppColors.rouge), + icon: const Icon(Icons.add_shopping_cart, + color: AppColors.rouge), tooltip: 'Restock', onPressed: () => _showRestockDialog(equipment), ), @@ -525,7 +567,10 @@ class _EquipmentManagementPageState extends State { IconButton( icon: const Icon(Icons.qr_code, color: AppColors.rouge), tooltip: 'QR Code', - onPressed: () => _showSingleQRCode(equipment), + onPressed: () => showDialog( + context: context, + builder: (context) => QRCodeDialog.forEquipment(equipment), + ), ), // Bouton Modifier (permission required) PermissionGate( @@ -547,8 +592,8 @@ class _EquipmentManagementPageState extends State { ), ], ), - onTap: _isSelectionMode - ? () => _toggleEquipmentSelection(equipment.id) + onTap: isSelectionMode + ? () => toggleItemSelection(equipment.id) : () => _viewEquipmentDetails(equipment), ), ); @@ -558,12 +603,15 @@ class _EquipmentManagementPageState extends State { final availableQty = equipment.availableQuantity ?? 0; final totalQty = equipment.totalQuantity ?? 0; final criticalThreshold = equipment.criticalThreshold ?? 0; - final isCritical = criticalThreshold > 0 && availableQty <= criticalThreshold; + 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), + 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, @@ -706,10 +754,13 @@ class _EquipmentManagementPageState extends State { onPressed: () async { Navigator.pop(context); try { - await context.read().deleteEquipment(equipment.id); + await context + .read() + .deleteEquipment(equipment.id); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Équipement supprimé avec succès')), + const SnackBar( + content: Text('Équipement supprimé avec succès')), ); } } catch (e) { @@ -729,14 +780,14 @@ class _EquipmentManagementPageState extends State { } void _deleteSelectedEquipment() async { - if (_selectedEquipmentIds.isEmpty) return; + if (!hasSelection) return; showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Confirmer la suppression'), content: Text( - 'Voulez-vous vraiment supprimer ${_selectedEquipmentIds.length} équipement(s) ?', + 'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?', ), actions: [ TextButton( @@ -748,17 +799,15 @@ class _EquipmentManagementPageState extends State { Navigator.pop(context); try { final provider = context.read(); - for (final id in _selectedEquipmentIds) { + for (final id in selectedIds) { await provider.deleteEquipment(id); } - setState(() { - _selectedEquipmentIds.clear(); - _isSelectionMode = false; - }); + disableSelectionMode(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('${_selectedEquipmentIds.length} équipement(s) supprimé(s) avec succès'), + content: Text( + '$selectedCount équipement(s) supprimé(s) avec succès'), backgroundColor: Colors.green, ), ); @@ -780,7 +829,7 @@ class _EquipmentManagementPageState extends State { } void _generateQRCodesForSelected() async { - if (_selectedEquipmentIds.isEmpty) return; + if (!hasSelection) return; // Récupérer les équipements sélectionnés final provider = context.read(); @@ -789,7 +838,7 @@ class _EquipmentManagementPageState extends State { // On doit récupérer les équipements depuis le stream await for (final equipmentList in provider.equipmentStream.take(1)) { for (final equipment in equipmentList) { - if (_selectedEquipmentIds.contains(equipment.id)) { + if (isItemSelected(equipment.id)) { selectedEquipment.add(equipment); } } @@ -799,486 +848,22 @@ class _EquipmentManagementPageState extends State { if (selectedEquipment.isEmpty) return; if (selectedEquipment.length == 1) { - _showSingleQRCode(selectedEquipment.first); + // Un seul équipement : afficher le dialogue simple + showDialog( + context: context, + builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first), + ); } else { - _showMultipleQRCodesDialog(selectedEquipment); - } - } - - void _showSingleQRCode(EquipmentModel equipment) { - showDialog( - context: context, - builder: (context) => Dialog( - child: Container( - padding: const EdgeInsets.all(24), - constraints: const BoxConstraints(maxWidth: 400), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - const Icon(Icons.qr_code, color: AppColors.rouge, size: 32), - const SizedBox(width: 12), - Expanded( - child: Text( - 'QR Code - ${equipment.id}', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - const SizedBox(height: 24), - // QR Code - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey[300]!), - ), - child: QrImageView( - data: equipment.id, - version: QrVersions.auto, - size: 250, - backgroundColor: Colors.white, - ), - ), - const SizedBox(height: 16), - // Informations - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - equipment.id, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - const SizedBox(height: 4), - Text( - '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim(), - style: TextStyle(color: Colors.grey[700]), - ), - ], - ), - ), - const SizedBox(height: 24), - // Bouton télécharger - ElevatedButton.icon( - onPressed: () => _downloadSingleQRCodeImage(equipment), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.rouge, - minimumSize: const Size(double.infinity, 48), - ), - icon: const Icon(Icons.download, color: Colors.white), - label: const Text( - 'Télécharger l\'image', - style: TextStyle(color: Colors.white), - ), - ), - ], - ), - ), - ), - ); - } - - void _showMultipleQRCodesDialog(List equipmentList) { - showDialog( - context: context, - builder: (context) => Dialog( - child: Container( - padding: const EdgeInsets.all(24), - constraints: const BoxConstraints(maxWidth: 600, maxHeight: 700), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - const Icon(Icons.qr_code_2, color: AppColors.rouge, size: 32), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Générer ${equipmentList.length} QR Codes', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - const SizedBox(height: 24), - const Text( - 'Choisissez un format d\'étiquette :', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 16), - // Liste des équipements - Expanded( - child: Container( - decoration: BoxDecoration( - border: Border.all(color: Colors.grey[300]!), - borderRadius: BorderRadius.circular(8), - ), - child: ListView.separated( - shrinkWrap: true, - itemCount: equipmentList.length, - separatorBuilder: (context, index) => const Divider(height: 1), - itemBuilder: (context, index) { - final equipment = equipmentList[index]; - return ListTile( - dense: true, - leading: const Icon(Icons.qr_code, size: 20), - title: Text( - equipment.id, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Text( - '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim(), - style: const TextStyle(fontSize: 12), - ), - ); - }, - ), - ), - ), - const SizedBox(height: 24), - // Boutons de format - _buildFormatButton( - context, - icon: Icons.qr_code, - title: 'Petits QR Codes', - subtitle: 'QR codes compacts (2x2 cm)', - onPressed: () { - Navigator.pop(context); - _generatePDF(equipmentList, QRLabelFormat.small); - }, - ), - const SizedBox(height: 12), - _buildFormatButton( - context, - icon: Icons.qr_code_2, - title: 'QR Moyens', - subtitle: 'QR codes taille moyenne (4x4 cm)', - onPressed: () { - Navigator.pop(context); - _generatePDF(equipmentList, QRLabelFormat.medium); - }, - ), - const SizedBox(height: 12), - _buildFormatButton( - context, - icon: Icons.label, - title: 'Grandes étiquettes', - subtitle: 'QR + ID + Marque/Modèle (10x5 cm)', - onPressed: () { - Navigator.pop(context); - _generatePDF(equipmentList, QRLabelFormat.large); - }, - ), - ], - ), - ), - ), - ); - } - - Widget _buildFormatButton( - BuildContext context, { - required IconData icon, - required String title, - required String subtitle, - required VoidCallback onPressed, - }) { - return InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey[300]!), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon(icon, color: AppColors.rouge, size: 32), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - const SizedBox(height: 4), - Text( - subtitle, - style: TextStyle( - color: Colors.grey[600], - fontSize: 13, - ), - ), - ], - ), - ), - const Icon(Icons.arrow_forward_ios, size: 16), - ], - ), - ), - ); - } - - Future _downloadSingleQRCodeImage(EquipmentModel equipment) async { - try { - // Générer l'image QR code en haute résolution - final qrImage = await _generateQRImage(equipment.id, size: 1024); - - // Utiliser la bibliothèque printing pour sauvegarder l'image - await Printing.sharePdf( - bytes: qrImage, - filename: 'QRCode_${equipment.id}.png', - ); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Image QR Code téléchargée avec succès'), - backgroundColor: Colors.green, - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Erreur lors du téléchargement de l\'image: $e')), - ); - } - } - } - - Future _generatePDF(List equipmentList, QRLabelFormat format) async { - try { - final pdf = pw.Document(); - - switch (format) { - case QRLabelFormat.small: - await _generateSmallQRCodesPDF(pdf, equipmentList); - break; - case QRLabelFormat.medium: - await _generateMediumQRCodesPDF(pdf, equipmentList); - break; - case QRLabelFormat.large: - await _generateLargeQRCodesPDF(pdf, equipmentList); - break; - } - - await Printing.layoutPdf( - onLayout: (format) async => pdf.save(), - name: 'QRCodes_${DateTime.now().millisecondsSinceEpoch}.pdf', - ); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('PDF généré avec succès'), - backgroundColor: Colors.green, - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Erreur lors de la génération du PDF: $e')), - ); - } - } - } - - Future _generateSmallQRCodesPDF(pw.Document pdf, List equipmentList) async { - // Petits QR codes : 2x2 cm, 9 par page (3x3) - const qrSize = 56.69; // 2cm en points - const itemsPerRow = 4; - const itemsPerPage = 20; - - for (int pageStart = 0; pageStart < equipmentList.length; pageStart += itemsPerPage) { - final pageEquipment = equipmentList.skip(pageStart).take(itemsPerPage).toList(); - final qrImages = await Future.wait( - pageEquipment.map((eq) => _generateQRImage(eq.id)), - ); - - pdf.addPage( - pw.Page( - pageFormat: PdfPageFormat.a4, - margin: const pw.EdgeInsets.all(20), - build: (context) { - return pw.Wrap( - spacing: 10, - runSpacing: 10, - children: List.generate(pageEquipment.length, (index) { - return pw.Container( - width: qrSize, - height: qrSize, - child: pw.Image(pw.MemoryImage(qrImages[index])), - ); - }), - ); - }, + // Plusieurs équipements : afficher le sélecteur de format + showDialog( + context: context, + builder: (context) => QRCodeFormatSelectorDialog( + equipmentList: selectedEquipment, ), ); } } - Future _generateMediumQRCodesPDF(pw.Document pdf, List equipmentList) async { - // QR moyens : 4x4 cm, 6 par page (2x3) - const qrSize = 113.39; // 4cm en points - const itemsPerRow = 2; - const itemsPerPage = 6; - - for (int pageStart = 0; pageStart < equipmentList.length; pageStart += itemsPerPage) { - final pageEquipment = equipmentList.skip(pageStart).take(itemsPerPage).toList(); - final qrImages = await Future.wait( - pageEquipment.map((eq) => _generateQRImage(eq.id)), - ); - - pdf.addPage( - pw.Page( - pageFormat: PdfPageFormat.a4, - margin: const pw.EdgeInsets.all(20), - build: (context) { - return pw.Wrap( - spacing: 20, - runSpacing: 20, - children: List.generate(pageEquipment.length, (index) { - return pw.Container( - width: qrSize, - height: qrSize, - child: pw.Image(pw.MemoryImage(qrImages[index])), - ); - }), - ); - }, - ), - ); - } - } - - Future _generateLargeQRCodesPDF(pw.Document pdf, List equipmentList) async { - // Grandes étiquettes : 10x5 cm, 4 par page - const labelWidth = 283.46; // 10cm en points - const labelHeight = 141.73; // 5cm en points - const qrSize = 113.39; // 4cm en points - const itemsPerPage = 4; - - for (int pageStart = 0; pageStart < equipmentList.length; pageStart += itemsPerPage) { - final pageEquipment = equipmentList.skip(pageStart).take(itemsPerPage).toList(); - final qrImages = await Future.wait( - pageEquipment.map((eq) => _generateQRImage(eq.id)), - ); - - pdf.addPage( - pw.Page( - pageFormat: PdfPageFormat.a4, - margin: const pw.EdgeInsets.all(20), - build: (context) { - return pw.Wrap( - spacing: 10, - runSpacing: 10, - children: List.generate(pageEquipment.length, (index) { - final equipment = pageEquipment[index]; - return pw.Container( - width: labelWidth, - height: labelHeight, - padding: const pw.EdgeInsets.all(10), - decoration: pw.BoxDecoration( - border: pw.Border.all(color: PdfColors.grey300), - borderRadius: const pw.BorderRadius.all(pw.Radius.circular(5)), - ), - child: pw.Row( - children: [ - pw.Image(pw.MemoryImage(qrImages[index]), width: qrSize, height: qrSize), - pw.SizedBox(width: 15), - pw.Expanded( - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - mainAxisAlignment: pw.MainAxisAlignment.center, - children: [ - pw.Text( - equipment.id, - style: pw.TextStyle( - fontSize: 14, - fontWeight: pw.FontWeight.bold, - ), - ), - pw.SizedBox(height: 8), - pw.Text( - 'Marque: ${equipment.brand ?? 'N/A'}', - style: const pw.TextStyle(fontSize: 10), - ), - pw.SizedBox(height: 4), - pw.Text( - 'Modèle: ${equipment.model ?? 'N/A'}', - style: const pw.TextStyle(fontSize: 10), - ), - ], - ), - ), - ], - ), - ); - }), - ); - }, - ), - ); - } - } - - Future _generateQRImage(String data, {double size = 512}) async { - final qrValidationResult = QrValidator.validate( - data: data, - version: QrVersions.auto, - errorCorrectionLevel: QrErrorCorrectLevel.L, - ); - - if (qrValidationResult.status != QrValidationStatus.valid) { - throw Exception('QR code validation failed'); - } - - final qrCode = qrValidationResult.qrCode!; - final painter = QrPainter.withQr( - qr: qrCode, - color: const Color(0xFF000000), - emptyColor: const Color(0xFFFFFFFF), - gapless: true, - ); - - final picData = await painter.toImageData(size, format: ui.ImageByteFormat.png); - return picData!.buffer.asUint8List(); - } - void _showRestockDialog(EquipmentModel equipment) { final TextEditingController quantityController = TextEditingController(); bool addToTotal = false; @@ -1350,15 +935,18 @@ class _EquipmentManagementPageState extends State { border: OutlineInputBorder(), prefixIcon: Icon(Icons.inventory), hintText: 'Ex: 10 ou -5', - helperText: 'Nombre positif pour ajouter, négatif pour retirer', + helperText: + 'Nombre positif pour ajouter, négatif pour retirer', ), - keyboardType: const TextInputType.numberWithOptions(signed: true), + keyboardType: + const TextInputType.numberWithOptions(signed: true), autofocus: true, ), const SizedBox(height: 16), CheckboxListTile( title: const Text('Ajouter à la quantité totale'), - subtitle: const Text('Mettre à jour aussi la quantité totale'), + subtitle: + const Text('Mettre à jour aussi la quantité totale'), value: addToTotal, contentPadding: EdgeInsets.zero, onChanged: (bool? value) { @@ -1379,7 +967,8 @@ class _EquipmentManagementPageState extends State { final quantityText = quantityController.text.trim(); if (quantityText.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Veuillez entrer une quantité')), + const SnackBar( + content: Text('Veuillez entrer une quantité')), ); return; } @@ -1399,18 +988,23 @@ class _EquipmentManagementPageState extends State { final currentTotal = equipment.totalQuantity ?? 0; final newAvailable = currentAvailable + quantity; - final newTotal = addToTotal ? currentTotal + quantity : currentTotal; + final newTotal = + addToTotal ? currentTotal + quantity : currentTotal; if (newAvailable < 0) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('La quantité disponible ne peut pas être négative')), + const SnackBar( + content: Text( + 'La quantité disponible ne peut pas être négative')), ); return; } if (newTotal < 0) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('La quantité totale ne peut pas être négative')), + const SnackBar( + content: Text( + 'La quantité totale ne peut pas être négative')), ); return; } @@ -1422,9 +1016,9 @@ class _EquipmentManagementPageState extends State { }; await context.read().updateEquipment( - equipment.id, - updatedData, - ); + equipment.id, + updatedData, + ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -1449,7 +1043,8 @@ class _EquipmentManagementPageState extends State { style: ElevatedButton.styleFrom( backgroundColor: AppColors.rouge, ), - child: const Text('Valider', style: TextStyle(color: Colors.white)), + child: const Text('Valider', + style: TextStyle(color: Colors.white)), ), ], ); @@ -1458,17 +1053,12 @@ class _EquipmentManagementPageState extends State { ); } - void _showQRCode(EquipmentModel equipment) { - // TODO: Afficher le QR code - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('QR Code pour ${equipment.name} - À implémenter')), - ); - } - void _viewEquipmentDetails(EquipmentModel equipment) { - // TODO: Naviguer vers la page de détails - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Détails de ${equipment.name} - À implémenter')), + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => EquipmentDetailPage(equipment: equipment), + ), ); } } diff --git a/em2rp/lib/views/widgets/common/qr_code_dialog.dart b/em2rp/lib/views/widgets/common/qr_code_dialog.dart new file mode 100644 index 0000000..252a8a6 --- /dev/null +++ b/em2rp/lib/views/widgets/common/qr_code_dialog.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/services/qr_code_service.dart'; +import 'package:printing/printing.dart'; + +/// Widget réutilisable pour afficher un QR code avec option de téléchargement +/// Utilisable pour équipements, containers, et autres entités +class QRCodeDialog extends StatelessWidget { + final T item; + final String Function(T) getId; + final String Function(T) getTitle; + final List Function(T)? buildSubtitle; + + const QRCodeDialog({ + super.key, + required this.item, + required this.getId, + required this.getTitle, + this.buildSubtitle, + }); + + @override + Widget build(BuildContext context) { + final id = getId(item); + final title = getTitle(item); + + return Dialog( + child: Container( + padding: const EdgeInsets.all(24), + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // En-tête + Row( + children: [ + const Icon(Icons.qr_code, color: AppColors.rouge, size: 32), + const SizedBox(width: 12), + Expanded( + child: Text( + 'QR Code - $id', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 24), + + // QR Code + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: QrImageView( + data: id, + version: QrVersions.auto, + size: 250, + backgroundColor: Colors.white, + ), + ), + const SizedBox(height: 16), + + // Informations + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + id, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + title, + style: TextStyle(color: Colors.grey[700]), + ), + if (buildSubtitle != null) ...[ + const SizedBox(height: 4), + ...buildSubtitle!(item), + ], + ], + ), + ), + const SizedBox(height: 24), + + // Bouton télécharger + ElevatedButton.icon( + onPressed: () => _downloadQRCode(context, id), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.rouge, + minimumSize: const Size(double.infinity, 48), + ), + icon: const Icon(Icons.download, color: Colors.white), + label: const Text( + 'Télécharger l\'image', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), + ), + ); + } + + Future _downloadQRCode(BuildContext context, String id) async { + try { + // Générer l'image QR code en haute résolution + final qrImage = await QRCodeService.generateQRCode( + id, + size: 1024, + useCache: false, + ); + + // Utiliser la bibliothèque printing pour sauvegarder l'image + await Printing.sharePdf( + bytes: qrImage, + filename: 'QRCode_$id.png', + ); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Image QR Code téléchargée avec succès'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur lors du téléchargement: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + /// Factory pour équipement + static QRCodeDialog forEquipment(dynamic equipment) { + return QRCodeDialog( + item: equipment, + getId: (eq) => eq.id, + getTitle: (eq) => '${eq.brand ?? ''} ${eq.model ?? ''}'.trim(), + ); + } + + /// Factory pour container + static QRCodeDialog forContainer(dynamic container) { + return QRCodeDialog( + item: container, + getId: (c) => c.id, + getTitle: (c) => c.name, + buildSubtitle: (c) { + return [ + Text( + _getContainerTypeLabel(c.type), + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + ), + ), + ]; + }, + ); + } + + static String _getContainerTypeLabel(dynamic type) { + // Simple fallback - à améliorer avec import du model + return type.toString().split('.').last; + } +} + diff --git a/em2rp/lib/views/widgets/common/qr_code_format_selector_dialog.dart b/em2rp/lib/views/widgets/common/qr_code_format_selector_dialog.dart new file mode 100644 index 0000000..7044f34 --- /dev/null +++ b/em2rp/lib/views/widgets/common/qr_code_format_selector_dialog.dart @@ -0,0 +1,258 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/services/pdf_generator_service.dart'; +import 'package:printing/printing.dart'; + +/// Widget réutilisable pour sélectionner le format de génération de QR codes multiples +class QRCodeFormatSelectorDialog extends StatelessWidget { + final List equipmentList; + + const QRCodeFormatSelectorDialog({ + super.key, + required this.equipmentList, + }); + + @override + Widget build(BuildContext context) { + return Dialog( + child: Container( + padding: const EdgeInsets.all(24), + constraints: const BoxConstraints(maxWidth: 600, maxHeight: 700), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // En-tête + Row( + children: [ + const Icon(Icons.qr_code_2, color: AppColors.rouge, size: 32), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Générer ${equipmentList.length} QR Codes', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 24), + const Text( + 'Choisissez un format d\'étiquette :', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 16), + + // Liste des équipements + Expanded( + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey[300]!), + borderRadius: BorderRadius.circular(8), + ), + child: ListView.separated( + shrinkWrap: true, + itemCount: equipmentList.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final equipment = equipmentList[index]; + return ListTile( + dense: true, + leading: const Icon(Icons.qr_code, size: 20), + title: Text( + equipment.id, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim(), + style: const TextStyle(fontSize: 12), + ), + ); + }, + ), + ), + ), + const SizedBox(height: 24), + + // Boutons de format + _FormatButton( + icon: Icons.qr_code, + title: 'Petits QR Codes', + subtitle: 'QR codes compacts (2x2 cm)', + onPressed: () { + Navigator.pop(context); + _generatePDF(context, equipmentList, QRLabelFormat.small); + }, + ), + const SizedBox(height: 12), + _FormatButton( + icon: Icons.qr_code_2, + title: 'QR Moyens', + subtitle: 'QR codes taille moyenne (4x4 cm)', + onPressed: () { + Navigator.pop(context); + _generatePDF(context, equipmentList, QRLabelFormat.medium); + }, + ), + const SizedBox(height: 12), + _FormatButton( + icon: Icons.label, + title: 'Grandes étiquettes', + subtitle: 'QR + ID + Marque/Modèle (10x5 cm)', + onPressed: () { + Navigator.pop(context); + _generatePDF(context, equipmentList, QRLabelFormat.large); + }, + ), + ], + ), + ), + ); + } + + Future _generatePDF( + BuildContext context, + List equipmentList, + QRLabelFormat format, + ) async { + // Afficher le dialogue de chargement + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => Dialog( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AppColors.rouge), + ), + const SizedBox(height: 20), + const Text( + 'Génération du PDF en cours...', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Génération de ${equipmentList.length} QR code(s)', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ), + ); + + try { + // Génération du PDF + final pdfBytes = await PDFGeneratorService.generateQRCodesPDF( + equipmentList: equipmentList, + format: format, + ); + + // Fermer le dialogue de chargement + if (context.mounted) { + Navigator.pop(context); + } + + // Afficher le PDF + await Printing.layoutPdf( + onLayout: (format) async => pdfBytes, + name: 'QRCodes_${DateTime.now().millisecondsSinceEpoch}.pdf', + ); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('PDF généré avec succès'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + // Fermer le dialogue de chargement en cas d'erreur + if (context.mounted) { + Navigator.pop(context); + } + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur lors de la génération du PDF: $e')), + ); + } + } + } +} + +/// Bouton de sélection de format +class _FormatButton extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onPressed; + + const _FormatButton({ + required this.icon, + required this.title, + required this.subtitle, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey[300]!), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(icon, color: AppColors.rouge, size: 32), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: TextStyle( + color: Colors.grey[600], + fontSize: 13, + ), + ), + ], + ), + ), + const Icon(Icons.arrow_forward_ios, size: 16), + ], + ), + ), + ); + } +} + diff --git a/em2rp/lib/views/widgets/containers/container_equipment_tile.dart b/em2rp/lib/views/widgets/containers/container_equipment_tile.dart new file mode 100644 index 0000000..d72fbb0 --- /dev/null +++ b/em2rp/lib/views/widgets/containers/container_equipment_tile.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/models/equipment_model.dart'; + +/// Widget pour afficher un équipement dans la liste d'un container +class ContainerEquipmentTile extends StatelessWidget { + final EquipmentModel equipment; + final VoidCallback onView; + final VoidCallback onRemove; + + const ContainerEquipmentTile({ + super.key, + required this.equipment, + required this.onView, + required this.onRemove, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 8), + leading: CircleAvatar( + backgroundColor: AppColors.rouge.withOpacity(0.1), + child: const Icon(Icons.inventory_2, color: AppColors.rouge), + ), + title: Text( + equipment.id, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (equipment.brand != null || equipment.model != null) + Text('${equipment.brand ?? ''} ${equipment.model ?? ''}'), + const SizedBox(height: 4), + Row( + children: [ + _buildSmallBadge( + _getCategoryLabel(equipment.category), + Colors.blue, + ), + const SizedBox(width: 8), + if (equipment.weight != null) + _buildSmallBadge( + '${equipment.weight} kg', + Colors.grey, + ), + ], + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.visibility, size: 20), + tooltip: 'Voir détails', + onPressed: onView, + ), + IconButton( + icon: const Icon(Icons.remove_circle, color: Colors.red, size: 20), + tooltip: 'Retirer', + onPressed: onRemove, + ), + ], + ), + ); + } + + Widget _buildSmallBadge(String label, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + color: color, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + String _getCategoryLabel(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.lighting: + return 'Lumière'; + case EquipmentCategory.sound: + return 'Son'; + case EquipmentCategory.video: + return 'Vidéo'; + case EquipmentCategory.effect: + return 'Effets'; + case EquipmentCategory.structure: + return 'Structure'; + case EquipmentCategory.consumable: + return 'Consommable'; + case EquipmentCategory.cable: + return 'Câble'; + case EquipmentCategory.other: + return 'Autre'; + } + } +} + diff --git a/em2rp/lib/views/widgets/containers/container_header_card.dart b/em2rp/lib/views/widgets/containers/container_header_card.dart new file mode 100644 index 0000000..b46e89b --- /dev/null +++ b/em2rp/lib/views/widgets/containers/container_header_card.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/models/container_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; + +/// Widget pour afficher la carte d'en-tête d'un container +class ContainerHeaderCard extends StatelessWidget { + final ContainerModel container; + final List equipmentList; + + const ContainerHeaderCard({ + super.key, + required this.container, + required this.equipmentList, + }); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _getTypeIcon(container.type), + size: 60, + color: AppColors.rouge, + ), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + container.id, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + container.name, + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade700, + ), + ), + ], + ), + ), + ], + ), + const Divider(height: 32), + Row( + children: [ + Expanded( + child: _buildInfoItem( + context, + 'Type', + containerTypeLabel(container.type), + Icons.category, + ), + ), + Expanded( + child: _buildInfoItem( + context, + 'Statut', + _getStatusLabel(container.status), + Icons.info, + statusColor: _getStatusColor(container.status), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildInfoItem( + context, + 'Équipements', + '${container.itemCount}', + Icons.inventory, + ), + ), + Expanded( + child: _buildInfoItem( + context, + 'Poids total', + _calculateTotalWeight(), + Icons.scale, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildInfoItem( + BuildContext context, + String label, + String value, + IconData icon, { + Color? statusColor, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: Colors.grey.shade600), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: statusColor, + ), + ), + ], + ); + } + + String _calculateTotalWeight() { + if (equipmentList.isEmpty && container.weight == null) { + return '-'; + } + + final totalWeight = container.calculateTotalWeight(equipmentList); + 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: + return 'Disponible'; + case EquipmentStatus.inUse: + return 'En prestation'; + case EquipmentStatus.maintenance: + return 'Maintenance'; + case EquipmentStatus.outOfService: + return 'Hors service'; + default: + return 'Autre'; + } + } + + Color _getStatusColor(EquipmentStatus status) { + switch (status) { + case EquipmentStatus.available: + return Colors.green; + case EquipmentStatus.inUse: + return Colors.orange; + case EquipmentStatus.maintenance: + return Colors.blue; + case EquipmentStatus.outOfService: + return Colors.red; + default: + return Colors.grey; + } + } +} + diff --git a/em2rp/lib/views/widgets/containers/container_physical_characteristics.dart b/em2rp/lib/views/widgets/containers/container_physical_characteristics.dart new file mode 100644 index 0000000..1c18c44 --- /dev/null +++ b/em2rp/lib/views/widgets/containers/container_physical_characteristics.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/models/container_model.dart'; + +/// Widget pour afficher les caractéristiques physiques d'un container +class ContainerPhysicalCharacteristics extends StatelessWidget { + final ContainerModel container; + + const ContainerPhysicalCharacteristics({ + super.key, + required this.container, + }); + + @override + Widget build(BuildContext context) { + final hasDimensions = container.length != null || + container.width != null || + container.height != null; + final hasWeight = container.weight != null; + final hasVolume = container.volume != null; + + if (!hasDimensions && !hasWeight) { + return const SizedBox.shrink(); + } + + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Caractéristiques physiques', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Divider(height: 24), + if (hasWeight) + _buildCharacteristicRow( + 'Poids à vide', + '${container.weight} kg', + Icons.scale, + ), + if (hasDimensions) ...[ + if (hasWeight) const SizedBox(height: 12), + _buildCharacteristicRow( + 'Dimensions (L×l×H)', + '${container.length ?? '?'} × ${container.width ?? '?'} × ${container.height ?? '?'} cm', + Icons.straighten, + ), + ], + if (hasVolume) ...[ + const SizedBox(height: 12), + _buildCharacteristicRow( + 'Volume', + '${container.volume!.toStringAsFixed(3)} m³', + Icons.view_in_ar, + ), + ], + ], + ), + ), + ); + } + + Widget _buildCharacteristicRow(String label, String value, IconData icon) { + return Row( + children: [ + Icon(icon, size: 20, color: AppColors.rouge), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: const TextStyle(fontSize: 14), + ), + ), + Text( + value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } +} + diff --git a/em2rp/lib/views/widgets/equipment/equipment_parent_containers.dart b/em2rp/lib/views/widgets/equipment/equipment_parent_containers.dart new file mode 100644 index 0000000..08fac81 --- /dev/null +++ b/em2rp/lib/views/widgets/equipment/equipment_parent_containers.dart @@ -0,0 +1,177 @@ +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'; + +/// Widget pour afficher les containers parents d'un équipement +class EquipmentParentContainers extends StatefulWidget { + final List parentBoxIds; + + const EquipmentParentContainers({ + super.key, + required this.parentBoxIds, + }); + + @override + State createState() => _EquipmentParentContainersState(); +} + +class _EquipmentParentContainersState extends State { + List _containers = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadContainers(); + } + + Future _loadContainers() async { + if (widget.parentBoxIds.isEmpty) { + setState(() { + _isLoading = false; + }); + return; + } + + setState(() { + _isLoading = true; + }); + + try { + final containerProvider = context.read(); + final List 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 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, + ), + ), + ], + ), + 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; + } + } +} + diff --git a/em2rp/lib/views/widgets/nav/main_drawer.dart b/em2rp/lib/views/widgets/nav/main_drawer.dart index a7212b1..4158387 100644 --- a/em2rp/lib/views/widgets/nav/main_drawer.dart +++ b/em2rp/lib/views/widgets/nav/main_drawer.dart @@ -94,6 +94,37 @@ class MainDrawer extends StatelessWidget { ); }, ), + PermissionGate( + requiredPermissions: const ['view_equipment'], + child: ListTile( + leading: const Icon(Icons.inventory), + title: const Text('Gestion du Matériel'), + selected: currentPage == '/equipment_management', + selectedColor: AppColors.rouge, + onTap: () { + Navigator.pop(context); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => + const EquipmentManagementPage()), + ); + }, + ), + ), + 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, @@ -152,24 +183,6 @@ class MainDrawer extends StatelessWidget { }, ), ), - PermissionGate( - requiredPermissions: const ['view_equipment'], - child: ListTile( - leading: const Icon(Icons.inventory), - title: const Text('Gestion du Matériel'), - selected: currentPage == '/equipment_management', - selectedColor: AppColors.rouge, - onTap: () { - Navigator.pop(context); - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => - const EquipmentManagementPage()), - ); - }, - ), - ), ], ), ), diff --git a/em2rp/pubspec.yaml b/em2rp/pubspec.yaml index c44cfad..5d63db6 100644 --- a/em2rp/pubspec.yaml +++ b/em2rp/pubspec.yaml @@ -10,12 +10,12 @@ dependencies: flutter: sdk: flutter - firebase_core: ^3.12.1 - firebase_auth: ^5.5.1 - cloud_firestore: ^5.6.5 - google_sign_in: ^6.2.2 + firebase_core: ^4.2.0 + firebase_auth: ^6.1.1 + cloud_firestore: ^6.0.3 + google_sign_in: ^7.2.0 provider: ^6.1.2 - firebase_storage: ^12.4.4 + firebase_storage: ^13.0.3 image_picker: ^1.1.2 universal_io: ^2.2.2 cupertino_icons: ^1.0.2 @@ -29,7 +29,7 @@ dependencies: flutter_launcher_icons: ^0.14.3 flutter_native_splash: ^2.3.9 url_launcher: ^6.2.2 - share_plus: ^11.0.0 + share_plus: ^12.0.1 path_provider: ^2.1.2 pdf: ^3.10.7 printing: ^5.11.1 @@ -38,7 +38,7 @@ dependencies: timezone: ^0.10.1 flutter_secure_storage: ^9.0.0 http: ^1.1.2 - flutter_dotenv: ^5.1.0 + flutter_dotenv: ^6.0.0 google_fonts: ^6.1.0 flutter_svg: ^2.0.9 cached_network_image: ^3.3.1 @@ -60,7 +60,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 flutter: uses-material-design: true