feat(app): scan a printed seed label back into its record

Closes the physical↔digital loop the labels opened: the QR on a printed
envelope (tane://seed, already encoded by seed_label_codec) can now be
scanned from the inventory bar. A known label opens its variety; an
unknown one asks before adding anything, then creates the variety (species
linked by exact scientific name when bundled) with a lot carrying the
label's year and origin. Scanner is pure ZXing (zxing_barcode_scanner,
MIT) — no ML Kit, no Play Services, F-Droid-safe, the stack Ğ1nkgo ships.
Mobile-only; other platforms don't show the button (OCR precedent).
The post-scan flow is a plain function (handleScannedPayload) so it's
widget-tested without a camera.
This commit is contained in:
vjrj 2026-07-18 12:26:27 +02:00
parent bb5b3d4a43
commit f1aa8f8e66
18 changed files with 1130 additions and 42 deletions

View file

@ -70,6 +70,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
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$scan$en scan = Translations$scan$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);
@ -1200,6 +1201,36 @@ class Translations$printLabels$en {
String get cancelled => 'Cancelled';
}
// Path: scan
class Translations$scan$en {
Translations$scan$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Scan a seed label'
String get action => 'Scan a seed label';
/// en: 'Scan a label'
String get title => 'Scan a label';
/// en: 'That code is not a seed label'
String get notALabel => 'That code is not a seed label';
/// en: 'Not in your collection'
String get addTitle => 'Not in your collection';
/// en: 'Add “{label}” to your seeds?'
String addBody({required Object label}) => 'Add “${label}” to your seeds?';
/// en: 'Add it'
String get add => 'Add it';
/// en: 'Added to your collection'
String get added => 'Added to your collection';
}
// Path: cropCalendar
class Translations$cropCalendar$en {
Translations$cropCalendar$en.internal(this._root);
@ -3039,6 +3070,13 @@ extension on Translations {
'printLabels.save' => 'Save labels',
'printLabels.saved' => 'Labels saved',
'printLabels.cancelled' => 'Cancelled',
'scan.action' => 'Scan a seed label',
'scan.title' => 'Scan a label',
'scan.notALabel' => 'That code is not a seed label',
'scan.addTitle' => 'Not in your collection',
'scan.addBody' => ({required Object label}) => 'Add “${label}” to your seeds?',
'scan.add' => 'Add it',
'scan.added' => 'Added to your collection',
'cropCalendar.add' => 'Crop calendar',
'cropCalendar.title' => 'Crop calendar',
'cropCalendar.sow' => 'Sow',
@ -3249,6 +3287,8 @@ extension on Translations {
'plantare.statusForgiven' => 'Settled',
'plantare.openSection' => 'Open',
'plantare.settledSection' => 'Done',
_ => null,
} ?? switch (path) {
'plantare.removeConfirm' => 'Remove this commitment?',
'plantare.returnBy' => ({required Object date}) => 'Return by ${date}',
'plantare.overdue' => 'overdue',
@ -3256,8 +3296,6 @@ extension on Translations {
'plantare.dueByHint' => 'A gentle reminder, never enforced',
'plantare.pickDate' => 'Pick a date',
'plantare.clearDate' => 'Clear date',
_ => null,
} ?? switch (path) {
'plantare.sectionTitle' => 'Commitments',
'plantare.propose' => 'Propose a signed Plantaré',
'plantare.proposeHelp' => 'Both of you keep the same promise, signed by both — proof that this seed changed hands and will be grown out and returned.',