import 'dart:math' as math; import 'dart:typed_data'; import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pw; import 'file_service.dart'; import 'pdf_fonts.dart'; /// How the labels are tiled on the page: many small stickers to peel onto /// packets, or fewer larger cards for jars and boxes. enum LabelSheetFormat { stickers, cards } /// One printable seed label. All strings arrive already localized — this /// service knows nothing about i18n or the domain. [qrData] is the opaque /// payload for the QR (built by the caller via `SeedLabelCodec`). class LabelSheetLabel { const LabelSheetLabel({ required this.varietyLabel, required this.qrData, this.commonName, this.scientificName, this.details, }); /// The variety's label, as its keeper writes it ("Acelga de Perales"). final String varietyLabel; /// The species' common name in the keeper's locale, shown uppercase on top /// ("ACELGA"), like the old hand-written labels. Null when unknown. final String? commonName; /// Scientific name of the linked species (rendered in italics), if any. final String? scientificName; /// The batch facts line ("2006 · Perales · 2 cobs"), if any. final String? details; /// The QR payload (a `tane://seed` URI), so the seed's data rides on the /// physical packet. final String qrData; } /// Fixed page geometry per [LabelSheetFormat], in PDF points. class _Geometry { const _Geometry({ required this.columns, required this.rowHeight, required this.qr, required this.common, required this.title, required this.small, }); final int columns; final double rowHeight; final double qr; final double common; final double title; final double small; } /// Renders a sheet of seed labels (each with a QR) as a PDF and hands it to the /// user via the save dialog — a paper bridge that needs no network. Pure of /// platform channels apart from loading the bundled font, so it runs (and is /// tested) on the host. /// /// The embedded DejaVu face covers Latin (extended), Cyrillic and Greek, with /// Noto Arabic + Noto CJK as per-glyph fallbacks (see [buildPdfTheme]) so RTL /// and CJK text render. Fonts are a per-locale resource, extensible to any /// script. class LabelSheetService { LabelSheetService({required FileService files}) : _files = files; final FileService _files; /// Outer page margin (points, ~8.5 mm). static const _margin = 24.0; static _Geometry _geometryOf(LabelSheetFormat format) => switch (format) { // ~3 columns of ~64 mm stickers, tight text. LabelSheetFormat.stickers => const _Geometry( columns: 3, rowHeight: 104, qr: 46, common: 7, title: 9, small: 6.5, ), // ~2 columns of ~96 mm cards, room for the full facts line. LabelSheetFormat.cards => const _Geometry( columns: 2, rowHeight: 152, qr: 82, common: 9, title: 13, small: 9, ), }; Future buildPdf({ required List labels, required LabelSheetFormat format, bool rtl = false, }) async { final textDirection = rtl ? pw.TextDirection.rtl : pw.TextDirection.ltr; final fonts = await loadPdfFonts(); final doc = pw.Document(theme: fonts.theme); final g = _geometryOf(format); final labelWidth = (PdfPageFormat.a4.width - _margin * 2) / g.columns; pw.Widget labelBox(LabelSheetLabel label) => pw.Container( width: labelWidth, height: g.rowHeight, padding: const pw.EdgeInsets.all(6), decoration: pw.BoxDecoration( border: pw.Border.all(color: PdfColors.grey400, width: 0.5), ), child: pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.center, children: [ pw.BarcodeWidget( barcode: pw.Barcode.qrCode(), data: label.qrData, width: g.qr, height: g.qr, drawText: false, ), pw.SizedBox(width: 6), pw.Expanded( // Only the label's text mirrors for RTL; the grid itself stays // left-to-right (a physical sheet has no reading direction). child: pw.Directionality( textDirection: textDirection, child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, mainAxisAlignment: pw.MainAxisAlignment.center, mainAxisSize: pw.MainAxisSize.min, children: [ if (label.commonName != null) pw.Text( label.commonName!.toUpperCase(), maxLines: 1, overflow: pw.TextOverflow.clip, style: pw.TextStyle( font: fonts.bold, fontSize: g.common, color: PdfColors.grey800, ), ), pw.Text( label.varietyLabel, maxLines: 2, overflow: pw.TextOverflow.clip, style: pw.TextStyle(font: fonts.bold, fontSize: g.title), ), if (label.scientificName != null) pw.Text( label.scientificName!, maxLines: 1, overflow: pw.TextOverflow.clip, style: pw.TextStyle( fontSize: g.small, fontStyle: pw.FontStyle.italic, color: PdfColors.grey700, ), ), if (label.details != null) pw.Text( label.details!, maxLines: 2, overflow: pw.TextOverflow.clip, style: pw.TextStyle(fontSize: g.small), ), ], ), ), ), ], ), ); // One MultiPage child per grid row, so pagination breaks cleanly between // rows and never splits a label. The last row is padded with empty cells to // keep the grid left-aligned. final rows = []; for (var i = 0; i < labels.length; i += g.columns) { final end = math.min(i + g.columns, labels.length); rows.add( pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ for (var j = i; j < end; j++) labelBox(labels[j]), for (var k = end - i; k < g.columns; k++) pw.SizedBox(width: labelWidth), ], ), ); } doc.addPage( pw.MultiPage( pageFormat: PdfPageFormat.a4, margin: const pw.EdgeInsets.all(_margin), build: (context) => rows, ), ); return doc.save(); } /// Builds the sheet and asks the user where to keep it. Returns true when /// saved, false when they cancelled the dialog. Future saveLabels({ required List labels, required LabelSheetFormat format, required String suggestedName, bool rtl = false, }) async { final bytes = await buildPdf(labels: labels, format: format, rtl: rtl); final path = await _files.saveFile( suggestedName: suggestedName, bytes: bytes, ); return path != null; } }