feat: Gestion complète des containers et refactorisation du matériel
Ajout de la gestion des containers (création, édition, suppression, affichage des détails).
Introduction d'un système de génération de QR codes unifié et d'un mode de sélection multiple.
**Features:**
- **Gestion des Containers :**
- Nouvelle page de gestion des containers (`container_management_page.dart`) avec recherche et filtres.
- Formulaire de création/édition de containers (`container_form_page.dart`) avec génération d'ID automatique.
- Page de détails d'un container (`container_detail_page.dart`) affichant son contenu et ses caractéristiques.
- Ajout des routes et du provider (`ContainerProvider`) nécessaires.
- **Modèle de Données :**
- Ajout du `ContainerModel` pour représenter les boîtes, flight cases, etc.
- Le modèle `EquipmentModel` a été enrichi avec des caractéristiques physiques (poids, dimensions).
- **QR Codes :**
- Nouveau service unifié (`UnifiedPDFGeneratorService`) pour générer des PDFs de QR codes pour n'importe quelle entité.
- Services `PDFGeneratorService` et `ContainerPDFGeneratorService` transformés en wrappers pour maintenir la compatibilité.
- Amélioration de la performance de la génération de QR codes en masse.
- **Interface Utilisateur (UI/UX) :**
- Nouvelle page de détails pour le matériel (`equipment_detail_page.dart`).
- Ajout d'un `SelectionModeMixin` pour gérer la sélection multiple dans les pages de gestion.
- Dialogues réutilisables pour l'affichage de QR codes (`QRCodeDialog`) et la sélection de format d'impression (`QRCodeFormatSelectorDialog`).
- Ajout d'un bouton "Gérer les boîtes" sur la page de gestion du matériel.
**Refactorisation :**
- L' `IdGenerator` a été déplacé dans le répertoire `utils` et étendu pour gérer les containers.
- Mise à jour de nombreuses dépendances `pubspec.yaml` vers des versions plus récentes.
- Séparation de la logique d'affichage des containers et du matériel dans des widgets dédiés (`ContainerHeaderCard`, `EquipmentParentContainers`, etc.).
This commit is contained in:
52
em2rp/lib/services/container_pdf_generator_service.dart
Normal file
52
em2rp/lib/services/container_pdf_generator_service.dart
Normal file
@@ -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<Uint8List> generateQRCodesPDF({
|
||||
required List<ContainerModel> containerList,
|
||||
required Map<String, List<EquipmentModel>> 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 = <String>[
|
||||
'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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
378
em2rp/lib/services/container_service.dart
Normal file
378
em2rp/lib/services/container_service.dart
Normal file
@@ -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<void> 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<void> updateContainer(String id, Map<String, dynamic> 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<void> 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<String, dynamic>,
|
||||
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<ContainerModel?> getContainerById(String id) async {
|
||||
try {
|
||||
final doc = await _containersCollection.doc(id).get();
|
||||
if (doc.exists) {
|
||||
return ContainerModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('Error getting container: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupérer tous les containers
|
||||
Stream<List<ContainerModel>> 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<ContainerModel> containerList = snapshot.docs
|
||||
.map((doc) => ContainerModel.fromMap(doc.data() as Map<String, dynamic>, 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<Map<String, dynamic>> 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<String, dynamic>,
|
||||
equipmentDoc.id,
|
||||
);
|
||||
|
||||
// Avertir si l'équipement est déjà dans d'autres containers
|
||||
List<String> 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<void> 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<String, dynamic>,
|
||||
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<Map<String, dynamic>> 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<String> 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<String, dynamic>,
|
||||
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<List<EquipmentModel>> getContainerEquipment(String containerId) async {
|
||||
try {
|
||||
final container = await getContainerById(containerId);
|
||||
if (container == null) return [];
|
||||
|
||||
List<EquipmentModel> 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<String, dynamic>, doc.id));
|
||||
}
|
||||
}
|
||||
|
||||
return equipment;
|
||||
} catch (e) {
|
||||
print('Error getting container equipment: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Trouver tous les containers contenant un équipement spécifique
|
||||
Future<List<ContainerModel>> 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<String, dynamic>, doc.id))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
print('Error finding containers with equipment: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ajouter une entrée d'historique
|
||||
Future<void> _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<bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<List<EquipmentModel>> getEquipmentsByIds(List<String> ids) async {
|
||||
try {
|
||||
if (ids.isEmpty) return [];
|
||||
|
||||
final equipments = <EquipmentModel>[];
|
||||
|
||||
// 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<String, dynamic>,
|
||||
doc.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return equipments;
|
||||
} catch (e) {
|
||||
print('Error getting equipments by IDs: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupérer les maintenances pour un équipement
|
||||
Future<List<MaintenanceModel>> getMaintenancesForEquipment(String equipmentId) async {
|
||||
try {
|
||||
final maintenanceQuery = await _firestore
|
||||
.collection('maintenances')
|
||||
.where('equipmentIds', arrayContains: equipmentId)
|
||||
.orderBy('scheduledDate', descending: true)
|
||||
.get();
|
||||
|
||||
final maintenances = <MaintenanceModel>[];
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
76
em2rp/lib/services/pdf_generator_service.dart
Normal file
76
em2rp/lib/services/pdf_generator_service.dart
Normal file
@@ -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<Uint8List> generateQRCodesPDF({
|
||||
required List<EquipmentModel> 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 = <String>[];
|
||||
|
||||
// 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
173
em2rp/lib/services/qr_code_service.dart
Normal file
173
em2rp/lib/services/qr_code_service.dart
Normal file
@@ -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<String, Uint8List> _qrCache = {};
|
||||
static ui.Image? _cachedLogoImage;
|
||||
|
||||
/// Génère un QR code simple sans logo
|
||||
static Future<Uint8List> 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<Uint8List> 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<ui.Image> _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<List<Uint8List>> generateBulkQRCodes(
|
||||
List<String> 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<Uint8List> 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;
|
||||
}
|
||||
}
|
||||
|
||||
354
em2rp/lib/services/unified_pdf_generator_service.dart
Normal file
354
em2rp/lib/services/unified_pdf_generator_service.dart
Normal file
@@ -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<void> _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<Uint8List> generateSimpleQRCodesPDF<T>({
|
||||
required List<T> 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<Uint8List> generateAdvancedQRCodesPDF<T>({
|
||||
required List<T> items,
|
||||
required String Function(T) getId,
|
||||
required String Function(T) getTitle,
|
||||
required List<String> 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<void> _generateSmallQRCodesPDF<T>(
|
||||
pw.Document pdf,
|
||||
List<T> 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<void> _generateMediumQRCodesPDF<T>(
|
||||
pw.Document pdf,
|
||||
List<T> 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<void> _generateLargeQRCodesPDF<T>(
|
||||
pw.Document pdf,
|
||||
List<T> items,
|
||||
String Function(T) getId,
|
||||
String Function(T)? getTitle,
|
||||
List<String> 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user