386 lines
14 KiB
Dart
386 lines
14 KiB
Dart
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];
|
|
// Déterminer si c'est la première colonne (indices pairs)
|
|
final bool isFirstColumn = (i % 2) == 0;
|
|
// Décalage de 2mm pour la première colonne
|
|
final double leftPadding = isFirstColumn ? 8.0 : 6.0; // 6 + 2mm
|
|
|
|
return pw.Container(
|
|
width: labelWidth,
|
|
height: labelHeight,
|
|
padding: pw.EdgeInsets.only(
|
|
left: leftPadding,
|
|
right: 6,
|
|
top: 6,
|
|
bottom: 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;
|
|
}
|
|
} |