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:
vjrj 2026-07-10 19:14:04 +02:00
parent 432acded29
commit efb369eb81
19 changed files with 1465 additions and 116 deletions

View file

@ -262,6 +262,23 @@
"catalogSaved": "Catalog saved",
"cancelled": "Cancelled"
},
"printLabels": {
"action": "Print labels",
"title": "Print labels",
"selectHint": "Pick the seeds to print labels for",
"selectAll": "Select all",
"selected": "{n} selected",
"none": "Select seeds first",
"format": "Label size",
"formatStickers": "Small stickers",
"formatStickersHint": "Many small labels per page — to peel onto packets",
"formatCards": "Large cards",
"formatCardsHint": "Fewer, bigger labels — for jars and boxes",
"count": "{n} labels",
"save": "Save labels",
"saved": "Labels saved",
"cancelled": "Cancelled"
},
"cropCalendar": {
"add": "Crop calendar",
"title": "Crop calendar",

View file

@ -262,6 +262,23 @@
"catalogSaved": "Catálogo guardado",
"cancelled": "Cancelado"
},
"printLabels": {
"action": "Imprimir etiquetas",
"title": "Imprimir etiquetas",
"selectHint": "Elige las semillas para imprimir sus etiquetas",
"selectAll": "Seleccionar todo",
"selected": "{n} seleccionadas",
"none": "Selecciona semillas primero",
"format": "Tamaño de etiqueta",
"formatStickers": "Pegatinas pequeñas",
"formatStickersHint": "Muchas etiquetas pequeñas por hoja — para pegar en sobres",
"formatCards": "Tarjetas grandes",
"formatCardsHint": "Menos etiquetas, más grandes — para botes y cajas",
"count": "{n} etiquetas",
"save": "Guardar etiquetas",
"saved": "Etiquetas guardadas",
"cancelled": "Cancelado"
},
"cropCalendar": {
"add": "Calendario de cultivo",
"title": "Calendario de cultivo",

View file

@ -258,6 +258,23 @@
"catalogSaved": "Catálogo guardado",
"cancelled": "Cancelado"
},
"printLabels": {
"action": "Imprimir etiquetas",
"title": "Imprimir etiquetas",
"selectHint": "Escolhe as sementes para imprimir as etiquetas",
"selectAll": "Selecionar tudo",
"selected": "{n} selecionadas",
"none": "Seleciona sementes primeiro",
"format": "Tamanho da etiqueta",
"formatStickers": "Autocolantes pequenos",
"formatStickersHint": "Muitas etiquetas pequenas por folha — para colar em envelopes",
"formatCards": "Cartões grandes",
"formatCardsHint": "Menos etiquetas, maiores — para frascos e caixas",
"count": "{n} etiquetas",
"save": "Guardar etiquetas",
"saved": "Etiquetas guardadas",
"cancelled": "Cancelado"
},
"cropCalendar": {
"add": "Calendário de cultivo",
"title": "Calendário de cultivo",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 3
/// Strings: 998 (332 per locale)
/// Strings: 1043 (347 per locale)
///
/// Built on 2026-07-10 at 10:26 UTC
/// Built on 2026-07-10 at 15:14 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View file

@ -63,6 +63,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final Translations$provenance$en provenance = Translations$provenance$en.internal(_root);
late final Translations$abundance$en abundance = Translations$abundance$en.internal(_root);
late final Translations$share$en share = Translations$share$en.internal(_root);
late final Translations$printLabels$en printLabels = Translations$printLabels$en.internal(_root);
late final Translations$cropCalendar$en cropCalendar = Translations$cropCalendar$en.internal(_root);
late final Translations$needsReproduction$en needsReproduction = Translations$needsReproduction$en.internal(_root);
late final Translations$preservation$en preservation = Translations$preservation$en.internal(_root);
@ -872,6 +873,60 @@ class Translations$share$en {
String get cancelled => 'Cancelled';
}
// Path: printLabels
class Translations$printLabels$en {
Translations$printLabels$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Print labels'
String get action => 'Print labels';
/// en: 'Print labels'
String get title => 'Print labels';
/// en: 'Pick the seeds to print labels for'
String get selectHint => 'Pick the seeds to print labels for';
/// en: 'Select all'
String get selectAll => 'Select all';
/// en: '{n} selected'
String selected({required Object n}) => '${n} selected';
/// en: 'Select seeds first'
String get none => 'Select seeds first';
/// en: 'Label size'
String get format => 'Label size';
/// en: 'Small stickers'
String get formatStickers => 'Small stickers';
/// en: 'Many small labels per page — to peel onto packets'
String get formatStickersHint => 'Many small labels per page — to peel onto packets';
/// en: 'Large cards'
String get formatCards => 'Large cards';
/// en: 'Fewer, bigger labels — for jars and boxes'
String get formatCardsHint => 'Fewer, bigger labels — for jars and boxes';
/// en: '{n} labels'
String count({required Object n}) => '${n} labels';
/// en: 'Save labels'
String get save => 'Save labels';
/// en: 'Labels saved'
String get saved => 'Labels saved';
/// en: 'Cancelled'
String get cancelled => 'Cancelled';
}
// Path: cropCalendar
class Translations$cropCalendar$en {
Translations$cropCalendar$en.internal(this._root);
@ -1876,6 +1931,21 @@ extension on Translations {
'share.catalogTitle' => 'What I share',
'share.catalogSaved' => 'Catalog saved',
'share.cancelled' => 'Cancelled',
'printLabels.action' => 'Print labels',
'printLabels.title' => 'Print labels',
'printLabels.selectHint' => 'Pick the seeds to print labels for',
'printLabels.selectAll' => 'Select all',
'printLabels.selected' => ({required Object n}) => '${n} selected',
'printLabels.none' => 'Select seeds first',
'printLabels.format' => 'Label size',
'printLabels.formatStickers' => 'Small stickers',
'printLabels.formatStickersHint' => 'Many small labels per page — to peel onto packets',
'printLabels.formatCards' => 'Large cards',
'printLabels.formatCardsHint' => 'Fewer, bigger labels — for jars and boxes',
'printLabels.count' => ({required Object n}) => '${n} labels',
'printLabels.save' => 'Save labels',
'printLabels.saved' => 'Labels saved',
'printLabels.cancelled' => 'Cancelled',
'cropCalendar.add' => 'Crop calendar',
'cropCalendar.title' => 'Crop calendar',
'cropCalendar.sow' => 'Sow',

View file

@ -62,6 +62,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$provenance$es provenance = _Translations$provenance$es._(_root);
@override late final _Translations$abundance$es abundance = _Translations$abundance$es._(_root);
@override late final _Translations$share$es share = _Translations$share$es._(_root);
@override late final _Translations$printLabels$es printLabels = _Translations$printLabels$es._(_root);
@override late final _Translations$cropCalendar$es cropCalendar = _Translations$cropCalendar$es._(_root);
@override late final _Translations$needsReproduction$es needsReproduction = _Translations$needsReproduction$es._(_root);
@override late final _Translations$preservation$es preservation = _Translations$preservation$es._(_root);
@ -487,6 +488,30 @@ class _Translations$share$es extends Translations$share$en {
@override String get cancelled => 'Cancelado';
}
// Path: printLabels
class _Translations$printLabels$es extends Translations$printLabels$en {
_Translations$printLabels$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get action => 'Imprimir etiquetas';
@override String get title => 'Imprimir etiquetas';
@override String get selectHint => 'Elige las semillas para imprimir sus etiquetas';
@override String get selectAll => 'Seleccionar todo';
@override String selected({required Object n}) => '${n} seleccionadas';
@override String get none => 'Selecciona semillas primero';
@override String get format => 'Tamaño de etiqueta';
@override String get formatStickers => 'Pegatinas pequeñas';
@override String get formatStickersHint => 'Muchas etiquetas pequeñas por hoja — para pegar en sobres';
@override String get formatCards => 'Tarjetas grandes';
@override String get formatCardsHint => 'Menos etiquetas, más grandes — para botes y cajas';
@override String count({required Object n}) => '${n} etiquetas';
@override String get save => 'Guardar etiquetas';
@override String get saved => 'Etiquetas guardadas';
@override String get cancelled => 'Cancelado';
}
// Path: cropCalendar
class _Translations$cropCalendar$es extends Translations$cropCalendar$en {
_Translations$cropCalendar$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -1228,6 +1253,21 @@ extension on TranslationsEs {
'share.catalogTitle' => 'Lo que comparto',
'share.catalogSaved' => 'Catálogo guardado',
'share.cancelled' => 'Cancelado',
'printLabels.action' => 'Imprimir etiquetas',
'printLabels.title' => 'Imprimir etiquetas',
'printLabels.selectHint' => 'Elige las semillas para imprimir sus etiquetas',
'printLabels.selectAll' => 'Seleccionar todo',
'printLabels.selected' => ({required Object n}) => '${n} seleccionadas',
'printLabels.none' => 'Selecciona semillas primero',
'printLabels.format' => 'Tamaño de etiqueta',
'printLabels.formatStickers' => 'Pegatinas pequeñas',
'printLabels.formatStickersHint' => 'Muchas etiquetas pequeñas por hoja — para pegar en sobres',
'printLabels.formatCards' => 'Tarjetas grandes',
'printLabels.formatCardsHint' => 'Menos etiquetas, más grandes — para botes y cajas',
'printLabels.count' => ({required Object n}) => '${n} etiquetas',
'printLabels.save' => 'Guardar etiquetas',
'printLabels.saved' => 'Etiquetas guardadas',
'printLabels.cancelled' => 'Cancelado',
'cropCalendar.add' => 'Calendario de cultivo',
'cropCalendar.title' => 'Calendario de cultivo',
'cropCalendar.sow' => 'Siembra',

View file

@ -62,6 +62,7 @@ class TranslationsPt extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$provenance$pt provenance = _Translations$provenance$pt._(_root);
@override late final _Translations$abundance$pt abundance = _Translations$abundance$pt._(_root);
@override late final _Translations$share$pt share = _Translations$share$pt._(_root);
@override late final _Translations$printLabels$pt printLabels = _Translations$printLabels$pt._(_root);
@override late final _Translations$cropCalendar$pt cropCalendar = _Translations$cropCalendar$pt._(_root);
@override late final _Translations$needsReproduction$pt needsReproduction = _Translations$needsReproduction$pt._(_root);
@override late final _Translations$preservation$pt preservation = _Translations$preservation$pt._(_root);
@ -483,6 +484,30 @@ class _Translations$share$pt extends Translations$share$en {
@override String get cancelled => 'Cancelado';
}
// Path: printLabels
class _Translations$printLabels$pt extends Translations$printLabels$en {
_Translations$printLabels$pt._(TranslationsPt root) : this._root = root, super.internal(root);
final TranslationsPt _root; // ignore: unused_field
// Translations
@override String get action => 'Imprimir etiquetas';
@override String get title => 'Imprimir etiquetas';
@override String get selectHint => 'Escolhe as sementes para imprimir as etiquetas';
@override String get selectAll => 'Selecionar tudo';
@override String selected({required Object n}) => '${n} selecionadas';
@override String get none => 'Seleciona sementes primeiro';
@override String get format => 'Tamanho da etiqueta';
@override String get formatStickers => 'Autocolantes pequenos';
@override String get formatStickersHint => 'Muitas etiquetas pequenas por folha — para colar em envelopes';
@override String get formatCards => 'Cartões grandes';
@override String get formatCardsHint => 'Menos etiquetas, maiores — para frascos e caixas';
@override String count({required Object n}) => '${n} etiquetas';
@override String get save => 'Guardar etiquetas';
@override String get saved => 'Etiquetas guardadas';
@override String get cancelled => 'Cancelado';
}
// Path: cropCalendar
class _Translations$cropCalendar$pt extends Translations$cropCalendar$en {
_Translations$cropCalendar$pt._(TranslationsPt root) : this._root = root, super.internal(root);
@ -1220,6 +1245,21 @@ extension on TranslationsPt {
'share.catalogTitle' => 'O que partilho',
'share.catalogSaved' => 'Catálogo guardado',
'share.cancelled' => 'Cancelado',
'printLabels.action' => 'Imprimir etiquetas',
'printLabels.title' => 'Imprimir etiquetas',
'printLabels.selectHint' => 'Escolhe as sementes para imprimir as etiquetas',
'printLabels.selectAll' => 'Selecionar tudo',
'printLabels.selected' => ({required Object n}) => '${n} selecionadas',
'printLabels.none' => 'Seleciona sementes primeiro',
'printLabels.format' => 'Tamanho da etiqueta',
'printLabels.formatStickers' => 'Autocolantes pequenos',
'printLabels.formatStickersHint' => 'Muitas etiquetas pequenas por folha — para colar em envelopes',
'printLabels.formatCards' => 'Cartões grandes',
'printLabels.formatCardsHint' => 'Menos etiquetas, maiores — para frascos e caixas',
'printLabels.count' => ({required Object n}) => '${n} etiquetas',
'printLabels.save' => 'Guardar etiquetas',
'printLabels.saved' => 'Etiquetas guardadas',
'printLabels.cancelled' => 'Cancelado',
'cropCalendar.add' => 'Calendário de cultivo',
'cropCalendar.title' => 'Calendário de cultivo',
'cropCalendar.sow' => 'Sementeira',