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 0cecb943f0
commit bf091bf852
19 changed files with 1465 additions and 116 deletions

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(