feat(labels): print seed labels with QR from an inventory selection
Add multi-select to the inventory and a "Print labels" flow that renders a PDF sheet of labels (small stickers or large cards), each carrying the seed's common name, variety, scientific name, year/origin and a tanemaki://seed QR that holds the seed's data offline (future scan-to-import). - SeedLabelCodec: compact, versioned tanemaki://seed URI (encode/decode) - LabelSheetService: pure-Dart PDF grid (pdf + barcode + DejaVu), RTL-aware - VarietyRepository.labelRows: one row per lot, locale-picked common name - InventoryCubit selection mode; contextual app bar + label print sheet - i18n en/es/pt (printLabels.*) Tests: codec, PDF service (both formats + RTL), labelRows, selection cubit, inventory widget selection.
This commit is contained in:
parent
0cecb943f0
commit
bf091bf852
19 changed files with 1465 additions and 116 deletions
233
apps/app_seeds/lib/services/label_sheet_service.dart
Normal file
233
apps/app_seeds/lib/services/label_sheet_service.dart
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
|
||||
import 'file_service.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 `tanemaki://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. Like
|
||||
/// the OCR traineddata, the PDF font is a per-locale resource: wider scripts
|
||||
/// (Arabic, CJK) ship alongside their locales, not hardcoded here.
|
||||
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<Uint8List> buildPdf({
|
||||
required List<LabelSheetLabel> labels,
|
||||
required LabelSheetFormat format,
|
||||
bool rtl = false,
|
||||
}) async {
|
||||
final textDirection = rtl ? pw.TextDirection.rtl : pw.TextDirection.ltr;
|
||||
final base = pw.Font.ttf(
|
||||
await rootBundle.load('assets/fonts/DejaVuSans.ttf'),
|
||||
);
|
||||
final bold = pw.Font.ttf(
|
||||
await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'),
|
||||
);
|
||||
final doc = pw.Document(
|
||||
theme: pw.ThemeData.withFont(base: base, bold: bold, italic: base),
|
||||
);
|
||||
|
||||
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: bold,
|
||||
fontSize: g.common,
|
||||
color: PdfColors.grey800,
|
||||
),
|
||||
),
|
||||
pw.Text(
|
||||
label.varietyLabel,
|
||||
maxLines: 2,
|
||||
overflow: pw.TextOverflow.clip,
|
||||
style: pw.TextStyle(font: 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 = <pw.Widget>[];
|
||||
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<bool> saveLabels({
|
||||
required List<LabelSheetLabel> 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue