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

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