Files
EM2_ERP/em2rp/lib/services/pdf_service.dart
ElPoyo 7e111ec041 refactor: Amélioration de la génération des étiquettes PDF
Cette mise à jour refactorise en profondeur la génération des étiquettes PDF (formats moyen et grand) pour correspondre précisément aux dimensions de planches d'étiquettes standards, remplaçant la mise en page approximative par un placement calculé au millimètre près. La version de l'application est également incrémentée à `1.0.4`.

**Changements principaux sur le `PDFService` :**

-   **Précision des formats d'étiquettes :**
    -   **Format Moyen :**
        -   Calibré pour des étiquettes de 49 x 26 mm, disposées en 4 colonnes et 10 lignes (40 par page).
        -   La mise en page est entièrement refaite : le QR code est à gauche et le texte (logo, ID, titre) est à droite, optimisant l'espace horizontal.
        -   Un calcul manuel des marges (`leftMargin`, `topMargin`) assure un alignement précis sur la page A4, avec une correction pour un centrage parfait.
    -   **Format Large :**
        -   Calibré pour des étiquettes de 105 x 57 mm, disposées en 2 colonnes et 5 lignes (10 par page).
        -   Utilise la moitié de la largeur d'une page A4 (`PdfPageFormat.a4.width / 2`) pour garantir un ajustement parfait des colonnes.
        -   La mise en page a été ajustée pour un meilleur centrage vertical du contenu dans l'étiquette.
        -   Suppression des bordures décoratives pour une impression directe sur planches prédécoupées.

-   **Améliorations générales :**
    -   Le logo de l'entreprise est désormais inclus également sur les étiquettes de format moyen.
    -   Les tailles de police et la troncature du texte ont été ajustées pour chaque format afin d'éviter les débordements et d'améliorer la lisibilité.
    -   Le code a été nettoyé, supprimant des commentaires et des paramètres de mise en page obsolètes (`pw.Center`, `spacing`, `runSpacing`).

-   **Mise à jour de la version :**
    -   La version de l'application est passée de `1.0.3` à `1.0.4`.
2026-01-16 19:23:57 +01:00

377 lines
14 KiB
Dart

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 }
/// Configuration pour la génération de PDF
class PDFGeneratorConfig {
final double qrCodeSize;
final int itemsPerPage;
final bool withProgress;
const PDFGeneratorConfig({
required this.qrCodeSize,
required this.itemsPerPage,
this.withProgress = false,
});
static const small = PDFGeneratorConfig(
qrCodeSize: 150,
itemsPerPage: 50,
);
// 4 colonnes x 10 lignes = 40 étiquettes
static const medium = PDFGeneratorConfig(
qrCodeSize: 150, // Réduit légèrement pour entrer dans 25.4mm de haut
itemsPerPage: 40,
);
// 2 colonnes x 5 lignes = 10 étiquettes
static const large = PDFGeneratorConfig(
qrCodeSize: 300,
itemsPerPage: 10,
);
static PDFGeneratorConfig fromFormat(QRLabelFormat format) {
switch (format) {
case QRLabelFormat.small:
return small;
case QRLabelFormat.medium:
return medium;
case QRLabelFormat.large:
return large;
}
}
}
/// Service UNIQUE et optimisé pour la génération de PDFs avec QR codes
class PDFService {
static Uint8List? _cachedLogoBytes;
static bool _logoLoadAttempted = false;
/// Charge le logo en cache une seule fois
static Future<void> _ensureLogoLoaded() async {
if (_logoLoadAttempted) return;
_logoLoadAttempted = true;
try {
final logoData = await rootBundle.load('assets/logos/LowQRectangleLogoBlack.png');
_cachedLogoBytes = logoData.buffer.asUint8List();
} catch (e) {
_cachedLogoBytes = Uint8List(0);
}
}
/// Tronque un texte
static String _truncate(String text, int max) {
return text.length <= max ? text : '${text.substring(0, max - 3)}...';
}
/// Génère un PDF avec QR codes pour n'importe quel type d'objet
static Future<Uint8List> generatePDF<T>({
required List<T> items,
required QRLabelFormat format,
required String Function(T) getId,
String Function(T)? getTitle,
List<String> Function(T)? getDetails,
Function(int current, int total)? onProgress,
}) async {
if (items.isEmpty) {
throw Exception('La liste d\'items est vide');
}
final config = PDFGeneratorConfig.fromFormat(format);
final pdf = pw.Document();
// Pré-charger le logo pour formats medium et large
if (format == QRLabelFormat.medium || format == QRLabelFormat.large) {
await _ensureLogoLoaded();
}
// Générer tous les QR codes en bulk avec progression
final allQRImages = await QRCodeService.generateBulkQRCodes(
items.map((item) => getId(item)).toList(),
size: config.qrCodeSize,
withLogo: false,
useCache: true,
onProgress: onProgress,
);
// Générer les pages
switch (format) {
case QRLabelFormat.small:
_addSmallLabels(pdf, items, getId, allQRImages, config);
break;
case QRLabelFormat.medium:
_addMediumLabels(pdf, items, getId, getTitle, allQRImages, config);
break;
case QRLabelFormat.large:
_addLargeLabels(pdf, items, getId, getTitle, getDetails, allQRImages, config);
break;
}
return pdf.save();
}
// ========================================================================
// PETITS LABELS (Original: 2x2 cm approx)
// ========================================================================
static void _addSmallLabels<T>(
pw.Document pdf,
List<T> items,
String Function(T) getId,
List<Uint8List> qrImages,
PDFGeneratorConfig config,
) {
const qrSize = 56.69; // ~2cm
for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) {
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
final pageQRs = qrImages.skip(pageStart).take(config.itemsPerPage).toList();
pdf.addPage(
pw.Page(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(20),
build: (_) => pw.Wrap(
spacing: 10,
runSpacing: 10,
children: List.generate(pageItems.length, (i) {
return pw.Container(
width: qrSize,
height: qrSize + 20,
child: pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.center,
children: [
pw.Image(pw.MemoryImage(pageQRs[i])),
pw.SizedBox(height: 2),
pw.Text(
getId(pageItems[i]),
style: pw.TextStyle(fontSize: 6, fontWeight: pw.FontWeight.bold),
textAlign: pw.TextAlign.center,
),
],
),
);
}),
),
),
);
}
}
// ========================================================================
// LABELS MOYENS (49 x 26 mm | 4 colonnes, 10 lignes)
// ========================================================================
static void _addMediumLabels<T>(
pw.Document pdf,
List<T> items,
String Function(T) getId,
String Function(T)? getTitle,
List<Uint8List> qrImages,
PDFGeneratorConfig config,
) {
// 1. Dimensions exactes des étiquettes
const double labelWidth = 50 * PdfPageFormat.mm;
const double labelHeight = 26.0 * PdfPageFormat.mm;
// 2. Calcul du centrage manuel
// Marge théorique = (210mm - (49*4)) / 2 = 7mm
// CORRECTION : On enlève 1.5mm pour réduire la marge de gauche (décalage vers la gauche)
const double horizontalCorrection = PdfPageFormat.mm;
final double leftMargin = ((PdfPageFormat.a4.width - (labelWidth * 4)) / 2) + horizontalCorrection;
// Centrage vertical standard
final double topMargin = (PdfPageFormat.a4.height - (labelHeight * 10)) / 2 -0.75;
for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) {
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
final pageQRs = qrImages.skip(pageStart).take(config.itemsPerPage).toList();
pdf.addPage(
pw.Page(
pageFormat: PdfPageFormat.a4,
// 3. Application des marges calculées (plus de pw.Center)
margin: pw.EdgeInsets.only(
left: leftMargin,
top: topMargin,
right: 0,
bottom: 0
),
build: (_) => pw.Wrap(
spacing: 0,
runSpacing: 0,
children: List.generate(pageItems.length, (i) {
return pw.Container(
width: labelWidth,
height: labelHeight,
padding: const pw.EdgeInsets.all(2),
child: pw.Row(
children: [
// QR Code à gauche
pw.Container(
width: labelHeight - 4,
height: labelHeight - 4,
child: pw.Image(pw.MemoryImage(pageQRs[i])),
),
pw.SizedBox(width: 4),
// Texte à droite
pw.Expanded(
child: pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
// Logo
if (_cachedLogoBytes != null && _cachedLogoBytes!.isNotEmpty)
pw.Container(
height: 12,
alignment: pw.Alignment.centerLeft,
margin: const pw.EdgeInsets.only(bottom: 2),
child: pw.Image(pw.MemoryImage(_cachedLogoBytes!)),
),
pw.Text(
getId(pageItems[i]),
style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold),
maxLines: 1,
),
if (getTitle != null)
pw.Text(
_truncate(getTitle(pageItems[i]), 18),
style: const pw.TextStyle(fontSize: 6, color: PdfColors.grey700),
maxLines: 2,
overflow: pw.TextOverflow.clip,
),
],
),
),
],
),
);
}),
),
),
);
}
}
// ========================================================================
// GRANDS LABELS (105 x 57 mm | 2 colonnes, 5 lignes)
// ========================================================================
static void _addLargeLabels<T>(
pw.Document pdf,
List<T> items,
String Function(T) getId,
String Function(T)? getTitle,
List<String> Function(T)? getDetails,
List<Uint8List> qrImages,
PDFGeneratorConfig config,
) {
// UTILISATION DE LA LARGEUR A4 DIVISÉE PAR 2
// Cela garantit que 2 colonnes rentrent pile poil (210mm / 2 = 105mm)
final double labelWidth = PdfPageFormat.a4.width / 2;
const double labelHeight = 57.0 * PdfPageFormat.mm;
const int cols = 2;
const int rows = 5;
final double totalGridWidth = labelWidth * cols;
final double totalGridHeight = labelHeight * rows;
const double innerQrSize = 45.0 * PdfPageFormat.mm;
for (int pageStart = 0; pageStart < items.length; pageStart += config.itemsPerPage) {
final pageItems = items.skip(pageStart).take(config.itemsPerPage).toList();
final pageQRs = qrImages.skip(pageStart).take(config.itemsPerPage).toList();
pdf.addPage(
pw.Page(
pageFormat: PdfPageFormat.a4,
margin: pw.EdgeInsets.zero,
build: (_) => pw.Center(
child: pw.Container(
width: totalGridWidth,
height: totalGridHeight,
child: pw.Wrap(
spacing: 0, // Très important : 0 espace entre les colonnes
runSpacing: 0, // 0 espace entre les lignes
children: List.generate(pageItems.length, (i) {
final item = pageItems[i];
return pw.Container(
width: labelWidth,
height: labelHeight,
padding: const pw.EdgeInsets.all(6),
// Suppression de la décoration (bordure)
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
// QR Code
pw.Container(
width: innerQrSize,
height: innerQrSize,
child: pw.Image(pw.MemoryImage(pageQRs[i])),
),
pw.SizedBox(width: 8),
// Détails
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
mainAxisAlignment: pw.MainAxisAlignment.center,
children: [
// Logo
if (_cachedLogoBytes != null && _cachedLogoBytes!.isNotEmpty)
pw.Container(
height: 20,
alignment: pw.Alignment.centerLeft,
margin: const pw.EdgeInsets.only(bottom: 4),
child: pw.Image(pw.MemoryImage(_cachedLogoBytes!)),
),
// Titre
if (getTitle != null) ...[
pw.Text(
_truncate(getTitle(item), 40),
style: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold),
maxLines: 2,
),
],
// ID
pw.SizedBox(height: 2),
pw.Text(
getId(item),
style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey700),
),
// Détails supplémentaires
if (getDetails != null) ...[
pw.SizedBox(height: 4),
...getDetails(item).take(4).map((line) {
return pw.Padding(
padding: const pw.EdgeInsets.only(bottom: 1),
child: pw.Text(
_truncate(line, 35),
style: const pw.TextStyle(fontSize: 7, color: PdfColors.grey800),
),
);
}),
],
],
),
),
],
),
);
}),
),
),
),
),
);
}
}
/// Nettoie le cache (logo)
static void clearCache() {
_cachedLogoBytes = null;
_logoLoadAttempted = false;
}
}