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

@ -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
@ -1237,6 +1280,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 {