Merge branch 'worktree-print-labels': print seed labels with QR

# Conflicts:
#	apps/app_seeds/lib/i18n/strings.g.dart
#	apps/app_seeds/lib/ui/inventory_list_screen.dart
This commit is contained in:
vjrj 2026-07-10 19:19:29 +02:00
commit 6c8a1ad129
25 changed files with 1522 additions and 115 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

View file

@ -0,0 +1,92 @@
import 'package:equatable/equatable.dart';
/// The seed facts a printed label carries in its QR code just enough to
/// identify the seed offline, so the data travels on the physical packet with
/// no server and no account. Kept deliberately small so the QR stays scannable
/// at sticker size.
class SeedLabelData extends Equatable {
const SeedLabelData({
required this.varietyLabel,
this.scientificName,
this.commonName,
this.year,
this.origin,
});
/// The variety's label, as its keeper writes it (e.g. "Acelga de Perales").
final String varietyLabel;
/// Scientific name of the linked species, if any (e.g. "Beta vulgaris").
final String? scientificName;
/// The species' common name in the keeper's locale (e.g. "Acelga").
final String? commonName;
/// Harvest year of the batch, if known.
final int? year;
/// Where the seed came from (origin name and/or place), if recorded.
final String? origin;
@override
List<Object?> get props => [
varietyLabel,
scientificName,
commonName,
year,
origin,
];
}
/// Encodes/decodes [SeedLabelData] as a compact, versioned `tanemaki://seed`
/// URI self-describing and parseable, so a future scan-to-import feature can
/// read a label a keeper printed today. Unicode and RTL text are percent-encoded
/// by [Uri], so any QR reader round-trips them safely.
///
/// Shape: `tanemaki://seed?v=<variety>&s=<scientific>&c=<common>&y=<year>&o=<origin>`
/// Absent fields are omitted, keeping the payload (and the QR) as small as the
/// data allows.
abstract final class SeedLabelCodec {
static const _scheme = 'tanemaki';
static const _host = 'seed';
static String encode(SeedLabelData data) {
final params = <String, String>{
'v': data.varietyLabel,
if (_present(data.scientificName)) 's': data.scientificName!.trim(),
if (_present(data.commonName)) 'c': data.commonName!.trim(),
if (data.year != null) 'y': '${data.year}',
if (_present(data.origin)) 'o': data.origin!.trim(),
};
return Uri(
scheme: _scheme,
host: _host,
queryParameters: params,
).toString();
}
/// Parses a `tanemaki://seed` URI back to [SeedLabelData]. Returns null when
/// [input] is not a well-formed seed label (wrong scheme/host, or no variety).
static SeedLabelData? decode(String input) {
final uri = Uri.tryParse(input.trim());
if (uri == null || uri.scheme != _scheme || uri.host != _host) return null;
final q = uri.queryParameters;
final variety = q['v']?.trim();
if (variety == null || variety.isEmpty) return null;
final year = q['y'];
return SeedLabelData(
varietyLabel: variety,
scientificName: _clean(q['s']),
commonName: _clean(q['c']),
year: year == null ? null : int.tryParse(year),
origin: _clean(q['o']),
);
}
static bool _present(String? value) => value != null && value.trim().isNotEmpty;
static String? _clean(String? value) {
final trimmed = value?.trim();
return (trimmed == null || trimmed.isEmpty) ? null : trimmed;
}
}

View file

@ -117,6 +117,49 @@ class SharedCatalogEntry extends Equatable {
];
}
/// One printable seed label for a selection of the inventory: a variety batch
/// (lot) with the facts a physical label carries the species common name in
/// the keeper's locale, the variety label, the scientific name, the harvest
/// year and where it came from. A variety with no lots yields a single
/// name-only entry. Built by [VarietyRepository.labelRows]; unlike
/// [SharedCatalogEntry] it is not restricted to shared lots you label what
/// you hold, private or not.
class SeedLabelEntry extends Equatable {
const SeedLabelEntry({
required this.varietyLabel,
this.commonName,
this.scientificName,
this.category,
this.harvestYear,
this.quantity,
this.originName,
this.originPlace,
});
final String varietyLabel;
/// The species' common name in the keeper's locale ("Acelga"), if known.
final String? commonName;
final String? scientificName;
final String? category;
final int? harvestYear;
final Quantity? quantity;
final String? originName;
final String? originPlace;
@override
List<Object?> get props => [
varietyLabel,
commonName,
scientificName,
category,
harvestYear,
quantity,
originName,
originPlace,
];
}
/// A lot the user has marked for sharing, carrying the lot [lotId] (so the
/// published offer has a stable, updatable id) plus the summary a network offer
/// needs. Distinct from [SharedCatalogEntry], which is for the printable local
@ -1243,6 +1286,123 @@ class VarietyRepository {
return entries;
}
/// Printable label rows for a selection of [varietyIds]: one entry per
/// non-deleted lot (year/origin/quantity are per batch), or a single
/// name-only entry for a selected variety that holds no lots. [languageCode]
/// picks the species common name shown on top. Ordered by category, label
/// then harvest year. Draft and deleted varieties are excluded.
Future<List<SeedLabelEntry>> labelRows(
Set<String> varietyIds, {
required String languageCode,
}) async {
if (varietyIds.isEmpty) return const [];
final varieties =
await (_db.select(_db.varieties)..where(
(v) =>
v.id.isIn(varietyIds) &
v.isDeleted.equals(false) &
v.isDraft.equals(false),
))
.get();
if (varieties.isEmpty) return const [];
final speciesIds = varieties
.map((v) => v.speciesId)
.whereType<String>()
.toSet();
final sciNames = await _scientificNamesFor(speciesIds);
final commonNames = await _commonNamesFor(speciesIds, languageCode);
final lots =
await (_db.select(_db.lots)..where(
(l) =>
l.varietyId.isIn(varieties.map((v) => v.id).toList()) &
l.isDeleted.equals(false),
))
.get();
final lotsByVariety = <String, List<Lot>>{};
for (final l in lots) {
(lotsByVariety[l.varietyId] ??= <Lot>[]).add(l);
}
SeedLabelEntry entryFor(Variety v, Lot? l) {
final hasQuantity =
l != null &&
(l.quantityKind != null ||
l.quantityPrecise != null ||
l.quantityLabel != null);
return SeedLabelEntry(
varietyLabel: v.label,
commonName: v.speciesId == null ? null : commonNames[v.speciesId],
scientificName: v.speciesId == null ? null : sciNames[v.speciesId],
category: v.category,
harvestYear: l?.harvestYear,
quantity: hasQuantity
? Quantity(
kind: _parseKind(l.quantityKind),
count: l.quantityPrecise,
label: l.quantityLabel,
)
: null,
originName: l?.originName,
originPlace: l?.originPlace,
);
}
final entries = <SeedLabelEntry>[];
for (final v in varieties) {
final vLots = lotsByVariety[v.id];
if (vLots == null || vLots.isEmpty) {
entries.add(entryFor(v, null));
} else {
for (final l in vLots) {
entries.add(entryFor(v, l));
}
}
}
entries.sort((a, b) {
final byCategory = (a.category ?? '').toLowerCase().compareTo(
(b.category ?? '').toLowerCase(),
);
if (byCategory != 0) return byCategory;
final byLabel = a.varietyLabel.toLowerCase().compareTo(
b.varietyLabel.toLowerCase(),
);
if (byLabel != 0) return byLabel;
return (a.harvestYear ?? 0).compareTo(b.harvestYear ?? 0);
});
return entries;
}
/// Maps each of [speciesIds] to its best common name for [languageCode] (one
/// query), preferring a name in the locale, else any. Species without a
/// common name are absent from the map.
Future<Map<String, String>> _commonNamesFor(
Set<String> speciesIds,
String languageCode,
) async {
if (speciesIds.isEmpty) return const {};
final rows =
await (_db.select(_db.speciesCommonNames)..where(
(n) => n.speciesId.isIn(speciesIds) & n.isDeleted.equals(false),
))
.get();
final bySpecies = <String, List<({String name, String? language})>>{};
for (final n in rows) {
(bySpecies[n.speciesId] ??= []).add((
name: n.name,
language: n.language,
));
}
final result = <String, String>{};
for (final entry in bySpecies.entries) {
final inLocale = entry.value.where((n) => n.language == languageCode);
result[entry.key] = (inLocale.isEmpty ? entry.value.first : inLocale.first)
.name;
}
return result;
}
/// Lots the user marked to share (not private), each with its id and the
/// variety label the input for publishing offers to the network (Block 2).
Future<List<ShareableLot>> shareableLots() async {

View file

@ -26,6 +26,7 @@ import '../services/locale_store.dart';
import '../services/ocr/label_text_extractor.dart';
import '../services/ocr/ocr_language.dart';
import '../services/ocr/tesseract_label_extractor.dart';
import '../services/label_sheet_service.dart';
import '../services/onboarding_store.dart';
import '../services/recovery_sheet_service.dart';
import '../services/share_catalog_service.dart';
@ -151,6 +152,9 @@ Future<void> configureDependencies() async {
)
..registerSingleton<RecoverySheetService>(
RecoverySheetService(files: fileService),
)
..registerSingleton<LabelSheetService>(
LabelSheetService(files: fileService),
);
// Automatic silent backups need real file storage; the web build has none, so

View file

@ -267,6 +267,23 @@
"catalogSaved": "Catálogu guardáu",
"cancelled": "Encaboxáu"
},
"printLabels": {
"action": "Imprentar etiquetes",
"title": "Imprentar etiquetes",
"selectHint": "Escueyi les semientes pa imprentar les sos etiquetes",
"selectAll": "Seleicionar too",
"selected": "{n} seleicionaes",
"none": "Seleiciona semientes primero",
"format": "Tamañu d'etiqueta",
"formatStickers": "Pegatines pequeñes",
"formatStickersHint": "Munches etiquetes pequeñes per fueya — pa pegar en sobres",
"formatCards": "Tarxetes grandes",
"formatCardsHint": "Menos etiquetes, más grandes — pa botes y caxes",
"count": "{n} etiquetes",
"save": "Guardar etiquetes",
"saved": "Etiquetes guardaes",
"cancelled": "Encaboxáu"
},
"cropCalendar": {
"add": "Calendariu de cultivu",
"title": "Calendariu de cultivu",

View file

@ -268,6 +268,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

@ -267,6 +267,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

@ -264,6 +264,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: 4
/// Strings: 1488 (372 per locale)
/// Strings: 1548 (387 per locale)
///
/// Built on 2026-07-10 at 17:11 UTC
/// Built on 2026-07-10 at 17:18 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View file

@ -63,6 +63,7 @@ class TranslationsAst extends Translations with BaseTranslations<AppLocale, Tran
@override late final _Translations$provenance$ast provenance = _Translations$provenance$ast._(_root);
@override late final _Translations$abundance$ast abundance = _Translations$abundance$ast._(_root);
@override late final _Translations$share$ast share = _Translations$share$ast._(_root);
@override late final _Translations$printLabels$ast printLabels = _Translations$printLabels$ast._(_root);
@override late final _Translations$cropCalendar$ast cropCalendar = _Translations$cropCalendar$ast._(_root);
@override late final _Translations$needsReproduction$ast needsReproduction = _Translations$needsReproduction$ast._(_root);
@override late final _Translations$preservation$ast preservation = _Translations$preservation$ast._(_root);
@ -501,6 +502,30 @@ class _Translations$share$ast extends Translations$share$en {
@override String get cancelled => 'Encaboxáu';
}
// Path: printLabels
class _Translations$printLabels$ast extends Translations$printLabels$en {
_Translations$printLabels$ast._(TranslationsAst root) : this._root = root, super.internal(root);
final TranslationsAst _root; // ignore: unused_field
// Translations
@override String get action => 'Imprentar etiquetes';
@override String get title => 'Imprentar etiquetes';
@override String get selectHint => 'Escueyi les semientes pa imprentar les sos etiquetes';
@override String get selectAll => 'Seleicionar too';
@override String selected({required Object n}) => '${n} seleicionaes';
@override String get none => 'Seleiciona semientes primero';
@override String get format => 'Tamañu d\'etiqueta';
@override String get formatStickers => 'Pegatines pequeñes';
@override String get formatStickersHint => 'Munches etiquetes pequeñes per fueya — pa pegar en sobres';
@override String get formatCards => 'Tarxetes grandes';
@override String get formatCardsHint => 'Menos etiquetes, más grandes — pa botes y caxes';
@override String count({required Object n}) => '${n} etiquetes';
@override String get save => 'Guardar etiquetes';
@override String get saved => 'Etiquetes guardaes';
@override String get cancelled => 'Encaboxáu';
}
// Path: cropCalendar
class _Translations$cropCalendar$ast extends Translations$cropCalendar$en {
_Translations$cropCalendar$ast._(TranslationsAst root) : this._root = root, super.internal(root);
@ -1288,6 +1313,21 @@ extension on TranslationsAst {
'share.catalogTitle' => 'Lo que comparto',
'share.catalogSaved' => 'Catálogu guardáu',
'share.cancelled' => 'Encaboxáu',
'printLabels.action' => 'Imprentar etiquetes',
'printLabels.title' => 'Imprentar etiquetes',
'printLabels.selectHint' => 'Escueyi les semientes pa imprentar les sos etiquetes',
'printLabels.selectAll' => 'Seleicionar too',
'printLabels.selected' => ({required Object n}) => '${n} seleicionaes',
'printLabels.none' => 'Seleiciona semientes primero',
'printLabels.format' => 'Tamañu d\'etiqueta',
'printLabels.formatStickers' => 'Pegatines pequeñes',
'printLabels.formatStickersHint' => 'Munches etiquetes pequeñes per fueya — pa pegar en sobres',
'printLabels.formatCards' => 'Tarxetes grandes',
'printLabels.formatCardsHint' => 'Menos etiquetes, más grandes — pa botes y caxes',
'printLabels.count' => ({required Object n}) => '${n} etiquetes',
'printLabels.save' => 'Guardar etiquetes',
'printLabels.saved' => 'Etiquetes guardaes',
'printLabels.cancelled' => 'Encaboxáu',
'cropCalendar.add' => 'Calendariu de cultivu',
'cropCalendar.title' => 'Calendariu de cultivu',
'cropCalendar.sow' => 'Sema',

View file

@ -64,6 +64,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);
@ -895,6 +896,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);
@ -2020,6 +2075,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

@ -63,6 +63,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);
@ -501,6 +502,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);
@ -1290,6 +1315,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

@ -63,6 +63,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);
@ -498,6 +499,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);
@ -1284,6 +1309,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',

View 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;
}
}

View file

@ -20,6 +20,8 @@ class InventoryState extends Equatable {
this.needsReproductionOnly = false,
this.sharingOnly = false,
this.loading = true,
this.selectionMode = false,
this.selectedIds = const {},
});
final List<VarietyListItem> items;
@ -49,6 +51,13 @@ class InventoryState extends Equatable {
final bool loading;
/// Whether the list is in multi-select mode used to pick a subset of the
/// inventory to print labels for.
final bool selectionMode;
/// Ids of the varieties currently selected in [selectionMode].
final Set<String> selectedIds;
/// Categories present across all items, in display order (deduped), so the
/// UI can offer one chip per category actually in use.
List<String> get categories {
@ -101,6 +110,8 @@ class InventoryState extends Equatable {
bool? needsReproductionOnly,
bool? sharingOnly,
bool? loading,
bool? selectionMode,
Set<String>? selectedIds,
}) {
return InventoryState(
items: items ?? this.items,
@ -113,6 +124,8 @@ class InventoryState extends Equatable {
needsReproductionOnly ?? this.needsReproductionOnly,
sharingOnly: sharingOnly ?? this.sharingOnly,
loading: loading ?? this.loading,
selectionMode: selectionMode ?? this.selectionMode,
selectedIds: selectedIds ?? this.selectedIds,
);
}
@ -127,6 +140,8 @@ class InventoryState extends Equatable {
needsReproductionOnly,
sharingOnly,
loading,
selectionMode,
selectedIds,
];
}
@ -177,6 +192,36 @@ class InventoryCubit extends Cubit<InventoryState> {
void toggleSharingOnly() =>
emit(state.copyWith(sharingOnly: !state.sharingOnly));
/// Enters multi-select mode with an empty selection (from the toolbar).
void startSelection() =>
emit(state.copyWith(selectionMode: true, selectedIds: const {}));
/// Enters multi-select mode with [id] as the first selected variety
/// (triggered by a long-press on a tile).
void enterSelection(String id) =>
emit(state.copyWith(selectionMode: true, selectedIds: {id}));
/// Toggles a variety in the selection (add if absent, remove if present).
/// An empty selection stays in selection mode; the user exits explicitly.
void toggleSelection(String id) {
final next = Set<String>.of(state.selectedIds);
if (!next.remove(id)) next.add(id);
emit(state.copyWith(selectionMode: true, selectedIds: next));
}
/// Selects every currently visible variety, so "filter to a category, then
/// select all" is the way to pick a subset of the inventory to print.
void selectAllVisible() => emit(
state.copyWith(
selectionMode: true,
selectedIds: state.visibleItems.map((i) => i.id).toSet(),
),
);
/// Leaves selection mode and clears the selection.
void exitSelection() =>
emit(state.copyWith(selectionMode: false, selectedIds: const {}));
/// Clears all filters (search is left untouched).
void clearFilters() => emit(
state.copyWith(

View file

@ -10,6 +10,7 @@ import '../i18n/strings.g.dart';
import '../services/share_catalog_service.dart';
import '../state/inventory_cubit.dart';
import 'draft_triage.dart';
import 'label_print_sheet.dart';
import 'quantity_kind_l10n.dart';
import 'quantity_picker.dart';
import 'quick_add_sheet.dart';
@ -25,88 +26,162 @@ class InventoryListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = context.t;
return Scaffold(
appBar: AppBar(
title: Text(t.inventory.title),
actions: [
// The paper bridge: only offered once something is marked to share.
BlocSelector<InventoryCubit, InventoryState, bool>(
selector: (state) => state.hasShared,
builder: (context, hasShared) => hasShared
? IconButton(
key: const Key('inventory.printCatalog'),
icon: const Icon(Icons.print_outlined),
tooltip: t.share.printCatalog,
onPressed: () => _printCatalog(context),
)
: const SizedBox.shrink(),
),
IconButton(
key: const Key('inventory.captureBurst'),
icon: const Icon(Icons.add_a_photo_outlined),
tooltip: t.draft.capture,
onPressed: () => _captureBurst(context),
),
],
),
floatingActionButton: FloatingActionButton(
key: const Key('inventory.addFab'),
tooltip: t.quickAdd.title,
onPressed: () => showQuickAddSheet(
context,
repository: context.read<VarietyRepository>(),
),
child: const Icon(Icons.add),
),
body: BlocBuilder<InventoryCubit, InventoryState>(
builder: (context, state) {
if (state.loading) {
return const Center(child: CircularProgressIndicator());
}
return Column(
children: [
if (state.drafts.isNotEmpty)
_TriageBanner(count: state.drafts.length),
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
child: TextField(
key: const Key('inventory.search'),
decoration: InputDecoration(
hintText: t.inventory.searchHint,
prefixIcon: const Icon(Icons.search, color: seedMuted),
hintStyle: const TextStyle(color: seedMuted),
filled: true,
fillColor: Colors.white,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide.none,
),
return BlocBuilder<InventoryCubit, InventoryState>(
builder: (context, state) {
return Scaffold(
appBar: state.selectionMode
? _selectionAppBar(context, state)
: _defaultAppBar(context, state),
// Inventory is a spoke off the home hub (no drawer the app bar's
// back arrow returns there). In selection mode the bars are
// contextual and the add FAB steps aside.
floatingActionButton: state.selectionMode
? null
: FloatingActionButton(
key: const Key('inventory.addFab'),
tooltip: t.quickAdd.title,
onPressed: () => showQuickAddSheet(
context,
repository: context.read<VarietyRepository>(),
),
onChanged: context.read<InventoryCubit>().search,
child: const Icon(Icons.add),
),
),
_FilterBar(state: state),
Expanded(
child: _InventoryBody(
items: state.visibleItems,
// Distinguish "no seeds at all" from "filters hid them all".
filtered:
state.categoryFilter.isNotEmpty ||
state.typeFilter.isNotEmpty ||
state.organicOnly ||
state.needsReproductionOnly ||
state.sharingOnly,
body: state.loading
? const Center(child: CircularProgressIndicator())
: Column(
children: [
if (state.drafts.isNotEmpty && !state.selectionMode)
_TriageBanner(count: state.drafts.length),
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
child: TextField(
key: const Key('inventory.search'),
decoration: InputDecoration(
hintText: t.inventory.searchHint,
prefixIcon: const Icon(Icons.search, color: seedMuted),
hintStyle: const TextStyle(color: seedMuted),
filled: true,
fillColor: Colors.white,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 14,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide.none,
),
),
onChanged: context.read<InventoryCubit>().search,
),
),
_FilterBar(state: state),
Expanded(
child: _InventoryBody(
items: state.visibleItems,
selectionMode: state.selectionMode,
selectedIds: state.selectedIds,
// Distinguish "no seeds at all" from "filters hid them".
filtered:
state.categoryFilter.isNotEmpty ||
state.typeFilter.isNotEmpty ||
state.organicOnly ||
state.needsReproductionOnly ||
state.sharingOnly,
),
),
],
),
),
],
);
},
),
);
},
);
}
/// The normal top bar: print-what-I-share, enter label selection, capture.
PreferredSizeWidget _defaultAppBar(
BuildContext context,
InventoryState state,
) {
final t = context.t;
return AppBar(
title: Text(t.inventory.title),
actions: [
// The paper bridge: only offered once something is marked to share.
if (state.hasShared)
IconButton(
key: const Key('inventory.printCatalog'),
icon: const Icon(Icons.print_outlined),
tooltip: t.share.printCatalog,
onPressed: () => _printCatalog(context),
),
// Enter multi-select to print labels only once there's something to
// label.
if (state.items.isNotEmpty)
IconButton(
key: const Key('inventory.selectLabels'),
icon: const Icon(Icons.label_outline),
tooltip: t.printLabels.action,
onPressed: context.read<InventoryCubit>().startSelection,
),
IconButton(
key: const Key('inventory.captureBurst'),
icon: const Icon(Icons.add_a_photo_outlined),
tooltip: t.draft.capture,
onPressed: () => _captureBurst(context),
),
],
);
}
/// The contextual bar shown while picking seeds to print labels for.
PreferredSizeWidget _selectionAppBar(
BuildContext context,
InventoryState state,
) {
final t = context.t;
final cubit = context.read<InventoryCubit>();
return AppBar(
leading: IconButton(
key: const Key('inventory.selection.close'),
icon: const Icon(Icons.close),
tooltip: t.common.cancel,
onPressed: cubit.exitSelection,
),
title: Text(t.printLabels.selected(n: state.selectedIds.length)),
actions: [
TextButton(
key: const Key('inventory.selection.selectAll'),
onPressed: cubit.selectAllVisible,
child: Text(t.printLabels.selectAll),
),
IconButton(
key: const Key('inventory.selection.print'),
icon: const Icon(Icons.label_outline),
tooltip: t.printLabels.action,
onPressed: state.selectedIds.isEmpty
? null
: () => _printLabels(context, state.selectedIds),
),
],
);
}
/// Gathers the label rows for the selection and opens the print sheet.
Future<void> _printLabels(BuildContext context, Set<String> ids) async {
final t = context.t;
final repository = context.read<VarietyRepository>();
final messenger = ScaffoldMessenger.of(context);
final entries = await repository.labelRows(
ids,
languageCode: LocaleSettings.currentLocale.languageCode,
);
if (!context.mounted) return;
if (entries.isEmpty) {
messenger.showSnackBar(SnackBar(content: Text(t.printLabels.none)));
return;
}
await showLabelPrintSheet(context, entries);
}
/// Assembles the localized rows of the "what I share" catalog and saves it
/// as a PDF wherever the user chooses.
Future<void> _printCatalog(BuildContext context) async {
@ -301,7 +376,12 @@ class _FilterBar extends StatelessWidget {
}
class _InventoryBody extends StatelessWidget {
const _InventoryBody({required this.items, this.filtered = false});
const _InventoryBody({
required this.items,
this.filtered = false,
this.selectionMode = false,
this.selectedIds = const {},
});
final List<VarietyListItem> items;
@ -309,6 +389,12 @@ class _InventoryBody extends StatelessWidget {
/// than "no seeds yet".
final bool filtered;
/// Whether the list is in multi-select mode (tiles show a checkbox).
final bool selectionMode;
/// Ids currently selected, so each tile can reflect its checkbox state.
final Set<String> selectedIds;
@override
Widget build(BuildContext context) {
final t = context.t;
@ -346,7 +432,13 @@ class _InventoryBody extends StatelessWidget {
currentCategory = category;
rows.add(_CategoryHeader(title: category));
}
rows.add(_VarietyTile(item: item));
rows.add(
_VarietyTile(
item: item,
selectionMode: selectionMode,
selected: selectedIds.contains(item.id),
),
);
}
return ListView(children: rows);
}
@ -374,15 +466,35 @@ class _CategoryHeader extends StatelessWidget {
}
class _VarietyTile extends StatelessWidget {
const _VarietyTile({required this.item});
const _VarietyTile({
required this.item,
this.selectionMode = false,
this.selected = false,
});
final VarietyListItem item;
/// Whether the list is picking seeds to print labels for: tap toggles the
/// selection instead of opening the detail, and the tile shows a checkbox.
final bool selectionMode;
final bool selected;
@override
Widget build(BuildContext context) {
final cubit = context.read<InventoryCubit>();
void open() => context.push('/variety/${item.id}');
void toggle() => cubit.toggleSelection(item.id);
return ListTile(
leading: _Avatar(item: item),
key: Key('inventory.tile.${item.id}'),
selected: selectionMode && selected,
leading: selectionMode
? Checkbox(
key: Key('inventory.select.${item.id}'),
value: selected,
onChanged: (_) => toggle(),
)
: _Avatar(item: item),
title: Text(
item.label,
style: const TextStyle(fontWeight: FontWeight.bold),
@ -393,42 +505,47 @@ class _VarietyTile extends StatelessWidget {
item.scientificName!,
style: const TextStyle(fontStyle: FontStyle.italic),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.isShared)
Padding(
padding: const EdgeInsetsDirectional.only(end: 4),
child: Tooltip(
key: Key('inventory.shared.${item.id}'),
message: context.t.share.filterChip,
child: const Icon(
Icons.volunteer_activism_outlined,
size: 20,
// In selection mode the trailing actions (which navigate away) give way to
// the checkbox flow.
trailing: selectionMode
? null
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.isShared)
Padding(
padding: const EdgeInsetsDirectional.only(end: 4),
child: Tooltip(
key: Key('inventory.shared.${item.id}'),
message: context.t.share.filterChip,
child: const Icon(
Icons.volunteer_activism_outlined,
size: 20,
color: seedGreen,
),
),
),
if (item.isOrganic)
Padding(
padding: const EdgeInsetsDirectional.only(end: 4),
child: Tooltip(
key: Key('inventory.organic.${item.id}'),
message: context.t.editVariety.organic,
child: const Icon(Icons.eco, size: 20, color: seedGreen),
),
),
_ViabilityDot(item.viability),
IconButton(
icon: const Icon(Icons.edit_outlined),
// Action colour: this is a tap target, not secondary text.
color: seedGreen,
tooltip: context.t.common.edit,
onPressed: open,
),
),
],
),
if (item.isOrganic)
Padding(
padding: const EdgeInsetsDirectional.only(end: 4),
child: Tooltip(
key: Key('inventory.organic.${item.id}'),
message: context.t.editVariety.organic,
child: const Icon(Icons.eco, size: 20, color: seedGreen),
),
),
_ViabilityDot(item.viability),
IconButton(
icon: const Icon(Icons.edit_outlined),
// Action colour: this is a tap target, not secondary text.
color: seedGreen,
tooltip: context.t.common.edit,
onPressed: open,
),
],
),
onTap: open,
onTap: selectionMode ? toggle : open,
onLongPress: selectionMode ? null : () => cubit.enterSelection(item.id),
);
}
}

View file

@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import '../data/export_import/seed_label_codec.dart';
import '../data/variety_repository.dart';
import '../di/injector.dart';
import '../i18n/strings.g.dart';
import '../services/label_sheet_service.dart';
import 'quantity_kind_l10n.dart';
/// Opens the print-labels sheet for [entries] (already gathered from the
/// current selection): pick a label size, then save a PDF sheet of labels
/// each with a QR that carries the seed's data.
Future<void> showLabelPrintSheet(
BuildContext context,
List<SeedLabelEntry> entries,
) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => _LabelPrintSheet(entries: entries),
);
}
class _LabelPrintSheet extends StatefulWidget {
const _LabelPrintSheet({required this.entries});
final List<SeedLabelEntry> entries;
@override
State<_LabelPrintSheet> createState() => _LabelPrintSheetState();
}
class _LabelPrintSheetState extends State<_LabelPrintSheet> {
LabelSheetFormat _format = LabelSheetFormat.stickers;
bool _saving = false;
@override
Widget build(BuildContext context) {
final t = context.t;
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.printLabels.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
t.printLabels.count(n: widget.entries.length),
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
RadioGroup<LabelSheetFormat>(
groupValue: _format,
onChanged: _pick,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
RadioListTile<LabelSheetFormat>(
key: const Key('printLabels.format.stickers'),
value: LabelSheetFormat.stickers,
contentPadding: EdgeInsets.zero,
title: Text(t.printLabels.formatStickers),
subtitle: Text(t.printLabels.formatStickersHint),
),
RadioListTile<LabelSheetFormat>(
key: const Key('printLabels.format.cards'),
value: LabelSheetFormat.cards,
contentPadding: EdgeInsets.zero,
title: Text(t.printLabels.formatCards),
subtitle: Text(t.printLabels.formatCardsHint),
),
],
),
),
const SizedBox(height: 12),
Align(
alignment: AlignmentDirectional.centerEnd,
child: FilledButton.icon(
key: const Key('printLabels.save'),
onPressed: _saving ? null : _save,
icon: const Icon(Icons.save_alt_outlined),
label: Text(t.printLabels.save),
),
),
],
),
),
);
}
void _pick(LabelSheetFormat? value) {
if (value != null) setState(() => _format = value);
}
Future<void> _save() async {
final t = context.t;
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
final rtl = Directionality.of(context) == TextDirection.rtl;
setState(() => _saving = true);
final labels = [for (final e in widget.entries) _toLabel(t, e)];
final saved = await getIt<LabelSheetService>().saveLabels(
labels: labels,
format: _format,
suggestedName: 'tanemaki-labels-${_today()}.pdf',
rtl: rtl,
);
navigator.pop();
messenger.showSnackBar(
SnackBar(
content: Text(saved ? t.printLabels.saved : t.printLabels.cancelled),
),
);
}
/// Builds one printable label from a [SeedLabelEntry]: the visible fields plus
/// the QR payload (structured, so a future scan can read it).
LabelSheetLabel _toLabel(Translations t, SeedLabelEntry e) {
final origin = _origin(e);
final details = <String>[
if (e.harvestYear != null) '${e.harvestYear}',
?origin,
if (e.quantity != null) quantityDisplay(t, e.quantity!),
];
return LabelSheetLabel(
varietyLabel: e.varietyLabel,
commonName: e.commonName,
scientificName: e.scientificName,
details: details.isEmpty ? null : details.join(' · '),
qrData: SeedLabelCodec.encode(
SeedLabelData(
varietyLabel: e.varietyLabel,
scientificName: e.scientificName,
commonName: e.commonName,
year: e.harvestYear,
origin: origin,
),
),
);
}
String? _origin(SeedLabelEntry e) {
final parts = <String>[
if (e.originName != null && e.originName!.trim().isNotEmpty)
e.originName!.trim(),
if (e.originPlace != null && e.originPlace!.trim().isNotEmpty)
e.originPlace!.trim(),
];
return parts.isEmpty ? null : parts.join(' · ');
}
String _today() => DateTime.now().toIso8601String().substring(0, 10);
}

View file

@ -0,0 +1,90 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/export_import/seed_label_codec.dart';
void main() {
group('SeedLabelCodec.encode', () {
test('encodes all fields as a tanemaki://seed URI', () {
final uri = SeedLabelCodec.encode(
const SeedLabelData(
varietyLabel: 'Acelga de Perales',
scientificName: 'Beta vulgaris',
commonName: 'Acelga',
year: 2006,
origin: 'Perales',
),
);
expect(uri, startsWith('tanemaki://seed?'));
final parsed = Uri.parse(uri);
expect(parsed.scheme, 'tanemaki');
expect(parsed.host, 'seed');
expect(parsed.queryParameters, {
'v': 'Acelga de Perales',
's': 'Beta vulgaris',
'c': 'Acelga',
'y': '2006',
'o': 'Perales',
});
});
test('omits absent fields, keeping the payload small', () {
final uri = SeedLabelCodec.encode(
const SeedLabelData(varietyLabel: 'Alubia de Tolosa'),
);
final parsed = Uri.parse(uri);
expect(parsed.queryParameters, {'v': 'Alubia de Tolosa'});
expect(uri, isNot(contains('&s=')));
expect(uri, isNot(contains('y=')));
});
test('treats blank optional fields as absent', () {
final uri = SeedLabelCodec.encode(
const SeedLabelData(
varietyLabel: 'Maíz',
scientificName: ' ',
origin: '',
),
);
expect(Uri.parse(uri).queryParameters, {'v': 'Maíz'});
});
});
group('SeedLabelCodec round-trip', () {
test('decode reverses encode for full data', () {
const data = SeedLabelData(
varietyLabel: 'Tomate Rosa',
scientificName: 'Solanum lycopersicum',
commonName: 'Tomate',
year: 2024,
origin: 'Huerta de la abuela, León',
);
expect(SeedLabelCodec.decode(SeedLabelCodec.encode(data)), data);
});
test('survives unicode, spaces and separators', () {
const data = SeedLabelData(
varietyLabel: 'Grelo & nabo "do país"',
origin: 'A Coruña / Galiza',
commonName: 'بامية', // RTL script
);
final decoded = SeedLabelCodec.decode(SeedLabelCodec.encode(data));
expect(decoded, data);
});
});
group('SeedLabelCodec.decode', () {
test('rejects a foreign scheme', () {
expect(SeedLabelCodec.decode('https://seed?v=x'), isNull);
expect(SeedLabelCodec.decode('tanemaki://other?v=x'), isNull);
});
test('rejects a payload without a variety', () {
expect(SeedLabelCodec.decode('tanemaki://seed?s=Beta%20vulgaris'), isNull);
});
test('ignores an unparseable year rather than throwing', () {
final decoded = SeedLabelCodec.decode('tanemaki://seed?v=Maíz&y=old');
expect(decoded?.varietyLabel, 'Maíz');
expect(decoded?.year, isNull);
});
});
}

View file

@ -2,6 +2,7 @@ import 'dart:typed_data';
import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/species_repository.dart';
import 'package:tane/data/variety_repository.dart';
import 'package:tane/db/database.dart';
import 'package:tane/db/enums.dart';
@ -85,4 +86,51 @@ void main() {
expect(stamps.toSet().length, 2);
expect(() => stamps.map(Hlc.parse).toList(), returnsNormally);
});
group('labelRows', () {
test('expands one entry per lot and resolves the locale common name', () async {
final speciesRepo = newTestSpeciesRepository(db);
await speciesRepo.seedBundled(const [
SpeciesSeed(
scientificName: 'Beta vulgaris',
family: 'Amaranthaceae',
commonNames: {
'en': ['Chard'],
'es': ['Acelga'],
},
),
]);
final speciesId = (await db.select(db.species).getSingle()).id;
final v = await repo.addQuickVariety(
label: 'Acelga de Perales',
category: 'Amaranthaceae',
);
await repo.linkSpecies(v, speciesId);
await repo.addLot(varietyId: v, harvestYear: 2006, originName: 'Perales');
await repo.addLot(varietyId: v, harvestYear: 2008);
final rows = await repo.labelRows({v}, languageCode: 'es');
expect(rows.length, 2); // one per lot
expect(rows.first.commonName, 'Acelga'); // locale-picked, not 'Chard'
expect(rows.first.scientificName, 'Beta vulgaris');
expect(rows.map((r) => r.harvestYear), [2006, 2008]);
expect(rows.first.originName, 'Perales');
});
test('yields a single name-only entry for a variety with no lots', () async {
final v = await repo.addQuickVariety(label: 'Alubia de Tolosa');
final rows = await repo.labelRows({v}, languageCode: 'en');
expect(rows.single.varietyLabel, 'Alubia de Tolosa');
expect(rows.single.harvestYear, isNull);
expect(rows.single.commonName, isNull);
});
test('includes only varieties in the selection', () async {
final selected = await repo.addQuickVariety(label: 'Selected');
await repo.addQuickVariety(label: 'Not selected');
final rows = await repo.labelRows({selected}, languageCode: 'en');
expect(rows.map((r) => r.varietyLabel), ['Selected']);
});
});
}

View file

@ -0,0 +1,92 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/file_service.dart';
import 'package:tane/services/label_sheet_service.dart';
/// Captures the bytes handed to the save dialog instead of touching disk.
class _RecordingFileService implements FileService {
Uint8List? saved;
String? savedName;
@override
Future<String?> saveFile({
required String suggestedName,
required Uint8List bytes,
}) async {
saved = bytes;
savedName = suggestedName;
return '/dev/null/$suggestedName';
}
@override
Future<Uint8List?> pickFileBytes({List<String>? allowedExtensions}) async =>
null;
}
const _labels = [
LabelSheetLabel(
varietyLabel: 'Acelga de Perales',
commonName: 'Acelga',
scientificName: 'Beta vulgaris',
details: '2006 · Perales',
qrData: 'tanemaki://seed?v=Acelga+de+Perales&y=2006',
),
LabelSheetLabel(
varietyLabel: 'Alubia de Tolosa',
qrData: 'tanemaki://seed?v=Alubia+de+Tolosa',
),
];
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
for (final format in LabelSheetFormat.values) {
test('buildPdf renders a well-formed PDF for $format', () async {
final service = LabelSheetService(files: _RecordingFileService());
final bytes = await service.buildPdf(labels: _labels, format: format);
// %PDF header + non-trivial body; text content is compressed, so the
// human-readable assertions live in the widget/data tests.
expect(String.fromCharCodes(bytes.take(5)), '%PDF-');
expect(bytes.length, greaterThan(1000));
});
}
test('buildPdf renders a name-only label (no optional fields)', () async {
final service = LabelSheetService(files: _RecordingFileService());
final bytes = await service.buildPdf(
labels: const [
LabelSheetLabel(varietyLabel: 'Maíz', qrData: 'tanemaki://seed?v=Maíz'),
],
format: LabelSheetFormat.stickers,
);
expect(String.fromCharCodes(bytes.take(5)), '%PDF-');
});
test('buildPdf honours an RTL text direction', () async {
final service = LabelSheetService(files: _RecordingFileService());
final bytes = await service.buildPdf(
labels: const [
LabelSheetLabel(varietyLabel: 'بامية', qrData: 'tanemaki://seed?v=x'),
],
format: LabelSheetFormat.cards,
rtl: true,
);
expect(String.fromCharCodes(bytes.take(5)), '%PDF-');
});
test('saveLabels hands the PDF to the save dialog', () async {
final files = _RecordingFileService();
final service = LabelSheetService(files: files);
final saved = await service.saveLabels(
labels: _labels,
format: LabelSheetFormat.cards,
suggestedName: 'tanemaki-labels-2026-07-10.pdf',
);
expect(saved, isTrue);
expect(files.savedName, 'tanemaki-labels-2026-07-10.pdf');
expect(String.fromCharCodes(files.saved!.take(5)), '%PDF-');
});
}

View file

@ -147,4 +147,50 @@ void main() {
expect(state.categories, hasLength(2));
expect(state.categories.toSet(), {'Poaceae', 'Fabaceae'});
});
group('selection mode', () {
test('enterSelection starts the mode with one id', () async {
await repo.addQuickVariety(label: 'Bean');
final state = await waitFor(cubit, (s) => s.items.isNotEmpty);
final id = state.items.single.id;
cubit.enterSelection(id);
expect(cubit.state.selectionMode, isTrue);
expect(cubit.state.selectedIds, {id});
});
test('toggleSelection adds then removes, staying in mode', () async {
await repo.addQuickVariety(label: 'Bean');
final state = await waitFor(cubit, (s) => s.items.isNotEmpty);
final id = state.items.single.id;
cubit.toggleSelection(id);
expect(cubit.state.selectedIds, {id});
cubit.toggleSelection(id);
expect(cubit.state.selectedIds, isEmpty);
expect(cubit.state.selectionMode, isTrue);
});
test('selectAllVisible selects only the filtered subset', () async {
await repo.addQuickVariety(label: 'Bean', category: 'Fabaceae');
await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae');
final state = await waitFor(cubit, (s) => s.items.length == 2);
final beanId = state.items.firstWhere((i) => i.label == 'Bean').id;
cubit.toggleCategory('Fabaceae'); // hide the tomato
cubit.selectAllVisible();
expect(cubit.state.selectedIds, {beanId});
});
test('exitSelection leaves the mode and clears the selection', () async {
await repo.addQuickVariety(label: 'Bean');
final state = await waitFor(cubit, (s) => s.items.isNotEmpty);
cubit.enterSelection(state.items.single.id);
cubit.exitSelection();
expect(cubit.state.selectionMode, isFalse);
expect(cubit.state.selectedIds, isEmpty);
});
});
}

View file

@ -219,4 +219,66 @@ void main() {
await disposeTree(tester);
},
);
testWidgets('the label toolbar enters selection and Select all enables print', (
tester,
) async {
final repo = newTestRepository(db);
await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae');
await repo.addQuickVariety(label: 'Bean', category: 'Fabaceae');
await tester.pumpWidget(
wrapScreen(repository: repo, child: const InventoryListScreen()),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('inventory.selectLabels')));
await tester.pumpAndSettle();
// Nothing picked yet: the count reads zero and print is disabled.
expect(find.text('0 selected'), findsOneWidget);
expect(
tester
.widget<IconButton>(find.byKey(const Key('inventory.selection.print')))
.onPressed,
isNull,
);
await tester.tap(find.byKey(const Key('inventory.selection.selectAll')));
await tester.pumpAndSettle();
expect(find.text('2 selected'), findsOneWidget);
expect(
tester
.widget<IconButton>(find.byKey(const Key('inventory.selection.print')))
.onPressed,
isNotNull,
);
// Closing the contextual bar leaves selection mode.
await tester.tap(find.byKey(const Key('inventory.selection.close')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('inventory.selection.print')), findsNothing);
await disposeTree(tester);
});
testWidgets('long-pressing a tile enters selection with it picked', (
tester,
) async {
final repo = newTestRepository(db);
await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae');
await repo.addQuickVariety(label: 'Bean', category: 'Fabaceae');
await tester.pumpWidget(
wrapScreen(repository: repo, child: const InventoryListScreen()),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Tomato'));
await tester.pumpAndSettle();
expect(find.text('1 selected'), findsOneWidget);
expect(find.byType(Checkbox), findsNWidgets(2)); // one per tile
await disposeTree(tester);
});
}