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:
parent
bb5b3d4a43
commit
f1aa8f8e66
18 changed files with 1130 additions and 42 deletions
|
|
@ -1987,6 +1987,22 @@ class VarietyRepository {
|
|||
return id;
|
||||
}
|
||||
|
||||
/// The id of the non-deleted variety whose label matches [label]
|
||||
/// (case-insensitive, trimmed), or null. This is how a scanned envelope
|
||||
/// label finds its record: the label IS the identity a keeper printed.
|
||||
Future<String?> findVarietyIdByLabel(String label) async {
|
||||
final row =
|
||||
await (_db.select(_db.varieties)
|
||||
..where(
|
||||
(v) =>
|
||||
v.isDeleted.equals(false) &
|
||||
v.label.lower().equals(label.trim().toLowerCase()),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
return row?.id;
|
||||
}
|
||||
|
||||
/// Records how a season went for a lot — the optional, skippable answer to
|
||||
/// "how did it do?" asked when a harvest is noted. Returns the new row id.
|
||||
Future<String> addGardenOutcome({
|
||||
|
|
|
|||
|
|
@ -368,6 +368,15 @@
|
|||
"saved": "Labels saved",
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
"scan": {
|
||||
"action": "Scan a seed label",
|
||||
"title": "Scan a label",
|
||||
"notALabel": "That code is not a seed label",
|
||||
"addTitle": "Not in your collection",
|
||||
"addBody": "Add “{label}” to your seeds?",
|
||||
"add": "Add it",
|
||||
"added": "Added to your collection"
|
||||
},
|
||||
"cropCalendar": {
|
||||
"add": "Crop calendar",
|
||||
"title": "Crop calendar",
|
||||
|
|
|
|||
|
|
@ -367,6 +367,15 @@
|
|||
"saved": "Etiquetas guardadas",
|
||||
"cancelled": "Cancelado"
|
||||
},
|
||||
"scan": {
|
||||
"action": "Escanear una etiqueta",
|
||||
"title": "Escanear etiqueta",
|
||||
"notALabel": "Ese código no es una etiqueta de semillas",
|
||||
"addTitle": "No está en tu colección",
|
||||
"addBody": "¿Añadir «{label}» a tus semillas?",
|
||||
"add": "Añadirla",
|
||||
"added": "Añadida a tu colección"
|
||||
},
|
||||
"cropCalendar": {
|
||||
"add": "Calendario de cultivo",
|
||||
"title": "Calendario de cultivo",
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 7
|
||||
/// Strings: 3550 (507 per locale)
|
||||
/// Strings: 3564 (509 per locale)
|
||||
///
|
||||
/// Built on 2026-07-18 at 10:13 UTC
|
||||
/// Built on 2026-07-18 at 10:23 UTC
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
|||
@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$scan$es scan = _Translations$scan$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);
|
||||
|
|
@ -650,6 +651,22 @@ class _Translations$printLabels$es extends Translations$printLabels$en {
|
|||
@override String get cancelled => 'Cancelado';
|
||||
}
|
||||
|
||||
// Path: scan
|
||||
class _Translations$scan$es extends Translations$scan$en {
|
||||
_Translations$scan$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||
|
||||
final TranslationsEs _root; // ignore: unused_field
|
||||
|
||||
// Translations
|
||||
@override String get action => 'Escanear una etiqueta';
|
||||
@override String get title => 'Escanear etiqueta';
|
||||
@override String get notALabel => 'Ese código no es una etiqueta de semillas';
|
||||
@override String get addTitle => 'No está en tu colección';
|
||||
@override String addBody({required Object label}) => '¿Añadir «${label}» a tus semillas?';
|
||||
@override String get add => 'Añadirla';
|
||||
@override String get added => 'Añadida a tu colección';
|
||||
}
|
||||
|
||||
// Path: cropCalendar
|
||||
class _Translations$cropCalendar$es extends Translations$cropCalendar$en {
|
||||
_Translations$cropCalendar$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||
|
|
@ -1795,6 +1812,13 @@ extension on TranslationsEs {
|
|||
'printLabels.save' => 'Guardar etiquetas',
|
||||
'printLabels.saved' => 'Etiquetas guardadas',
|
||||
'printLabels.cancelled' => 'Cancelado',
|
||||
'scan.action' => 'Escanear una etiqueta',
|
||||
'scan.title' => 'Escanear etiqueta',
|
||||
'scan.notALabel' => 'Ese código no es una etiqueta de semillas',
|
||||
'scan.addTitle' => 'No está en tu colección',
|
||||
'scan.addBody' => ({required Object label}) => '¿Añadir «${label}» a tus semillas?',
|
||||
'scan.add' => 'Añadirla',
|
||||
'scan.added' => 'Añadida a tu colección',
|
||||
'cropCalendar.add' => 'Calendario de cultivo',
|
||||
'cropCalendar.title' => 'Calendario de cultivo',
|
||||
'cropCalendar.sow' => 'Siembra',
|
||||
|
|
@ -2006,6 +2030,8 @@ extension on TranslationsEs {
|
|||
'plantare.openSection' => 'Pendientes',
|
||||
'plantare.settledSection' => 'Hechos',
|
||||
'plantare.removeConfirm' => '¿Quitar este compromiso?',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'plantare.returnBy' => ({required Object date}) => 'Devolver antes del ${date}',
|
||||
'plantare.overdue' => 'vencido',
|
||||
'plantare.dueByLabel' => 'Devolver antes de (opcional)',
|
||||
|
|
@ -2013,8 +2039,6 @@ extension on TranslationsEs {
|
|||
'plantare.pickDate' => 'Elegir fecha',
|
||||
'plantare.clearDate' => 'Quitar fecha',
|
||||
'plantare.sectionTitle' => 'Compromisos',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'plantare.propose' => 'Proponer un Plantaré firmado',
|
||||
'plantare.proposeHelp' => 'Los dos guardáis la misma promesa, firmada por ambos: prueba de que esta semilla cambió de manos y se cultivará y devolverá.',
|
||||
'plantare.proposeTo' => ({required Object name}) => 'Con ${name}',
|
||||
|
|
|
|||
66
apps/app_seeds/lib/services/seed_label_scan.dart
Normal file
66
apps/app_seeds/lib/services/seed_label_scan.dart
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../data/export_import/seed_label_codec.dart';
|
||||
import '../data/species_repository.dart';
|
||||
import '../data/variety_repository.dart';
|
||||
|
||||
/// What scanning a QR yielded: not a seed label at all, a variety already in
|
||||
/// the collection ([varietyId] set), or a seed not held yet ([data] set — the
|
||||
/// UI asks before adding anything).
|
||||
class SeedScanResult extends Equatable {
|
||||
const SeedScanResult.notALabel() : varietyId = null, data = null;
|
||||
const SeedScanResult.match(String this.varietyId) : data = null;
|
||||
const SeedScanResult.unknown(SeedLabelData this.data) : varietyId = null;
|
||||
|
||||
final String? varietyId;
|
||||
final SeedLabelData? data;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [varietyId, data];
|
||||
}
|
||||
|
||||
/// Resolves a scanned QR payload against the collection: decodes the
|
||||
/// `tane://seed` label and looks the variety up by its label (the identity a
|
||||
/// keeper printed on the envelope). This is how a physical packet finds its
|
||||
/// way back to its record — even a re-saved one whose digital thread broke.
|
||||
Future<SeedScanResult> resolveScannedLabel(
|
||||
VarietyRepository repository,
|
||||
String raw,
|
||||
) async {
|
||||
final data = SeedLabelCodec.decode(raw);
|
||||
if (data == null) return const SeedScanResult.notALabel();
|
||||
final varietyId = await repository.findVarietyIdByLabel(data.varietyLabel);
|
||||
return varietyId == null
|
||||
? SeedScanResult.unknown(data)
|
||||
: SeedScanResult.match(varietyId);
|
||||
}
|
||||
|
||||
/// Adds the scanned seed to the collection — a variety named as the label
|
||||
/// says, linked to the bundled species when the scientific name matches
|
||||
/// exactly, with one lot carrying the label's harvest year and origin.
|
||||
/// Called only after the person confirmed the add prompt.
|
||||
Future<String> importScannedLabel({
|
||||
required VarietyRepository repository,
|
||||
required SpeciesRepository species,
|
||||
required SeedLabelData data,
|
||||
}) async {
|
||||
final varietyId = await repository.addQuickVariety(label: data.varietyLabel);
|
||||
|
||||
final scientific = data.scientificName?.trim();
|
||||
if (scientific != null && scientific.isNotEmpty) {
|
||||
final matches = await species.search(scientific, limit: 4);
|
||||
for (final m in matches) {
|
||||
if (m.scientificName.toLowerCase() == scientific.toLowerCase()) {
|
||||
await repository.linkSpecies(varietyId, m.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await repository.addLot(
|
||||
varietyId: varietyId,
|
||||
harvestYear: data.year,
|
||||
originName: data.origin,
|
||||
);
|
||||
return varietyId;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../data/species_repository.dart';
|
||||
import '../data/variety_repository.dart';
|
||||
import '../db/enums.dart';
|
||||
import '../di/injector.dart';
|
||||
|
|
@ -14,6 +15,7 @@ import 'draft_triage.dart';
|
|||
import 'edge_fade.dart';
|
||||
import 'filter_chips.dart';
|
||||
import 'label_print_sheet.dart';
|
||||
import 'qr_scan.dart';
|
||||
import 'quantity_kind_l10n.dart';
|
||||
import 'quantity_picker.dart';
|
||||
import 'quick_add_sheet.dart';
|
||||
|
|
@ -133,6 +135,15 @@ class InventoryListScreen extends StatelessWidget {
|
|||
tooltip: t.printLabels.action,
|
||||
onPressed: context.read<InventoryCubit>().startSelection,
|
||||
),
|
||||
// Scan a printed seed label back into its record — the physical
|
||||
// envelope re-finds its digital thread. Mobile-only (camera).
|
||||
if (qrScanSupported)
|
||||
IconButton(
|
||||
key: const Key('inventory.scanLabel'),
|
||||
icon: const Icon(Icons.qr_code_scanner),
|
||||
tooltip: t.scan.action,
|
||||
onPressed: () => _scanLabel(context),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('inventory.captureBurst'),
|
||||
icon: const Icon(Icons.add_a_photo_outlined),
|
||||
|
|
@ -233,6 +244,20 @@ class InventoryListScreen extends StatelessWidget {
|
|||
return parts.isEmpty ? null : parts.join(' · ');
|
||||
}
|
||||
|
||||
Future<void> _scanLabel(BuildContext context) async {
|
||||
final repository = context.read<VarietyRepository>();
|
||||
final species = context.read<SpeciesRepository>();
|
||||
final payload = await scanSeedLabelQr(context);
|
||||
if (payload == null || !context.mounted) return;
|
||||
await handleScannedPayload(
|
||||
context,
|
||||
repository: repository,
|
||||
species: species,
|
||||
payload: payload,
|
||||
openVariety: (id) => context.push('/variety/$id'),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _captureBurst(BuildContext context) async {
|
||||
final t = context.t;
|
||||
final repository = context.read<VarietyRepository>();
|
||||
|
|
|
|||
120
apps/app_seeds/lib/ui/qr_scan.dart
Normal file
120
apps/app_seeds/lib/ui/qr_scan.dart
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:zxing_barcode_scanner/zxing_barcode_scanner.dart';
|
||||
|
||||
import '../data/species_repository.dart';
|
||||
import '../data/variety_repository.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/seed_label_scan.dart';
|
||||
|
||||
/// Whether this platform can open the camera scanner. Mobile-only for now
|
||||
/// (pure-ZXing platform views; no Google Play Services) — elsewhere the scan
|
||||
/// button simply isn't offered, same pattern as the OCR capture.
|
||||
bool get qrScanSupported => !kIsWeb && (Platform.isAndroid || Platform.isIOS);
|
||||
|
||||
/// Opens the full-screen camera scanner and returns the first decoded QR
|
||||
/// payload, or null if the person backed out. (Ğ1nkgo's scanner pattern on
|
||||
/// the same pure-ZXing stack.)
|
||||
Future<String?> scanSeedLabelQr(BuildContext context) {
|
||||
return Navigator.of(context).push<String>(
|
||||
MaterialPageRoute<String>(builder: (_) => const _ScannerScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Acts on a scanned payload: a known seed opens its record (via
|
||||
/// [openVariety]); an unknown seed label asks before adding anything; anything
|
||||
/// else says so and moves on. Navigation is injected so the flow is
|
||||
/// widget-testable without a camera or a router.
|
||||
Future<void> handleScannedPayload(
|
||||
BuildContext context, {
|
||||
required VarietyRepository repository,
|
||||
required SpeciesRepository species,
|
||||
required String payload,
|
||||
required void Function(String varietyId) openVariety,
|
||||
}) async {
|
||||
final t = context.t;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final result = await resolveScannedLabel(repository, payload);
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (result.varietyId case final id?) {
|
||||
openVariety(id);
|
||||
return;
|
||||
}
|
||||
final data = result.data;
|
||||
if (data == null) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(t.scan.notALabel)));
|
||||
return;
|
||||
}
|
||||
|
||||
final add = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
key: const Key('scan.addPrompt'),
|
||||
title: Text(t.scan.addTitle),
|
||||
content: Text(t.scan.addBody(label: data.varietyLabel)),
|
||||
actions: [
|
||||
TextButton(
|
||||
key: const Key('scan.cancel'),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
FilledButton(
|
||||
key: const Key('scan.add'),
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(t.scan.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (add != true) return;
|
||||
|
||||
final varietyId = await importScannedLabel(
|
||||
repository: repository,
|
||||
species: species,
|
||||
data: data,
|
||||
);
|
||||
messenger.showSnackBar(SnackBar(content: Text(t.scan.added)));
|
||||
openVariety(varietyId);
|
||||
}
|
||||
|
||||
class _ScannerScreen extends StatefulWidget {
|
||||
const _ScannerScreen();
|
||||
|
||||
@override
|
||||
State<_ScannerScreen> createState() => _ScannerScreenState();
|
||||
}
|
||||
|
||||
class _ScannerScreenState extends State<_ScannerScreen> {
|
||||
bool _done = false;
|
||||
|
||||
void _onScan(List<BarcodeResult> results) {
|
||||
if (_done || !mounted || results.isEmpty) return;
|
||||
final text = results.first.text;
|
||||
if (text == null || text.isEmpty) return;
|
||||
_done = true;
|
||||
// A beat so the camera view settles before popping (Ğ1nkgo does the same).
|
||||
Future<void>.delayed(const Duration(milliseconds: 150), () {
|
||||
if (mounted) Navigator.of(context).pop(text);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(t.scan.title)),
|
||||
body: ZxingBarcodeScanner(
|
||||
onScan: _onScan,
|
||||
onError: (error) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(error.message ?? t.common.offline),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -60,6 +60,10 @@ dependencies:
|
|||
# Pure-Dart barcode/QR generation (Apache-2.0; already used transitively by
|
||||
# pdf). Painted on screen for the profile's shareable identity QR.
|
||||
barcode: ^2.2.9
|
||||
# Camera QR scanning via pure ZXing (MIT) — no ML Kit, no Google Play
|
||||
# Services (F-Droid-safe; same stack Ğ1nkgo ships). Mobile-only; other
|
||||
# platforms simply don't offer the scan button.
|
||||
zxing_barcode_scanner: ^1.0.3
|
||||
path: ^1.9.0
|
||||
path_provider: ^2.1.5
|
||||
|
||||
|
|
|
|||
109
apps/app_seeds/test/services/seed_label_scan_test.dart
Normal file
109
apps/app_seeds/test/services/seed_label_scan_test.dart
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/data/export_import/seed_label_codec.dart';
|
||||
import 'package:tane/data/species_repository.dart';
|
||||
import 'package:tane/db/database.dart';
|
||||
import 'package:tane/services/seed_label_scan.dart';
|
||||
|
||||
import '../support/test_support.dart';
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late var repo = newTestRepository(db);
|
||||
late SpeciesRepository species;
|
||||
|
||||
setUp(() {
|
||||
db = newTestDatabase();
|
||||
repo = newTestRepository(db);
|
||||
species = newTestSpeciesRepository(db);
|
||||
});
|
||||
|
||||
tearDown(() async => db.close());
|
||||
|
||||
group('resolveScannedLabel', () {
|
||||
test('rejects anything that is not a seed label', () async {
|
||||
expect(
|
||||
await resolveScannedLabel(repo, 'https://example.org'),
|
||||
const SeedScanResult.notALabel(),
|
||||
);
|
||||
expect(
|
||||
await resolveScannedLabel(repo, 'gibberish'),
|
||||
const SeedScanResult.notALabel(),
|
||||
);
|
||||
});
|
||||
|
||||
test('finds the existing variety by its label, case-insensitively',
|
||||
() async {
|
||||
final id = await repo.addQuickVariety(label: 'Tomate Rosa');
|
||||
final raw = SeedLabelCodec.encode(
|
||||
const SeedLabelData(varietyLabel: 'tomate rosa'),
|
||||
);
|
||||
expect(
|
||||
await resolveScannedLabel(repo, raw),
|
||||
SeedScanResult.match(id),
|
||||
);
|
||||
});
|
||||
|
||||
test('an unknown label comes back with its data for the add prompt',
|
||||
() async {
|
||||
final raw = SeedLabelCodec.encode(
|
||||
const SeedLabelData(
|
||||
varietyLabel: 'Acelga de Perales',
|
||||
scientificName: 'Beta vulgaris',
|
||||
year: 2024,
|
||||
origin: 'María · Aiako',
|
||||
),
|
||||
);
|
||||
final result = await resolveScannedLabel(repo, raw);
|
||||
expect(result.data?.varietyLabel, 'Acelga de Perales');
|
||||
expect(result.data?.year, 2024);
|
||||
});
|
||||
});
|
||||
|
||||
group('importScannedLabel', () {
|
||||
test('creates the variety with a lot carrying year and origin', () async {
|
||||
const data = SeedLabelData(
|
||||
varietyLabel: 'Acelga de Perales',
|
||||
year: 2024,
|
||||
origin: 'María · Aiako',
|
||||
);
|
||||
final varietyId = await importScannedLabel(
|
||||
repository: repo,
|
||||
species: species,
|
||||
data: data,
|
||||
);
|
||||
final varieties = await db.select(db.varieties).get();
|
||||
expect(varieties.single.id, varietyId);
|
||||
expect(varieties.single.label, 'Acelga de Perales');
|
||||
final lots = await db.select(db.lots).get();
|
||||
expect(lots.single.varietyId, varietyId);
|
||||
expect(lots.single.harvestYear, 2024);
|
||||
expect(lots.single.originName, 'María · Aiako');
|
||||
});
|
||||
|
||||
test('links the species by exact scientific name when bundled', () async {
|
||||
await species.seedBundled(const [
|
||||
SpeciesSeed(
|
||||
scientificName: 'Beta vulgaris',
|
||||
family: 'Amaranthaceae',
|
||||
commonNames: {
|
||||
'es': ['Acelga'],
|
||||
},
|
||||
),
|
||||
]);
|
||||
const data = SeedLabelData(
|
||||
varietyLabel: 'Una acelga cualquiera',
|
||||
scientificName: 'beta vulgaris',
|
||||
);
|
||||
final varietyId = await importScannedLabel(
|
||||
repository: repo,
|
||||
species: species,
|
||||
data: data,
|
||||
);
|
||||
final variety = await (db.select(
|
||||
db.varieties,
|
||||
)..where((v) => v.id.equals(varietyId))).getSingle();
|
||||
final bundled = await db.select(db.species).get();
|
||||
expect(variety.speciesId, bundled.single.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
133
apps/app_seeds/test/ui/qr_scan_test.dart
Normal file
133
apps/app_seeds/test/ui/qr_scan_test.dart
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/data/export_import/seed_label_codec.dart';
|
||||
import 'package:tane/data/species_repository.dart';
|
||||
import 'package:tane/data/variety_repository.dart';
|
||||
import 'package:tane/db/database.dart';
|
||||
import 'package:tane/ui/qr_scan.dart';
|
||||
|
||||
import '../support/test_support.dart';
|
||||
|
||||
/// Hosts a button that feeds [payload] to [handleScannedPayload], standing in
|
||||
/// for the camera — the handler is what carries the flow's logic.
|
||||
class _ScanHost extends StatelessWidget {
|
||||
const _ScanHost({
|
||||
required this.repository,
|
||||
required this.species,
|
||||
required this.payload,
|
||||
required this.opened,
|
||||
});
|
||||
|
||||
final VarietyRepository repository;
|
||||
final SpeciesRepository species;
|
||||
final String payload;
|
||||
final List<String> opened;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: ElevatedButton(
|
||||
key: const Key('scanHost.fire'),
|
||||
onPressed: () => handleScannedPayload(
|
||||
context,
|
||||
repository: repository,
|
||||
species: species,
|
||||
payload: payload,
|
||||
openVariety: opened.add,
|
||||
),
|
||||
child: const Text('scan'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late AppDatabase db;
|
||||
late VarietyRepository repo;
|
||||
late SpeciesRepository species;
|
||||
|
||||
setUp(() {
|
||||
db = newTestDatabase();
|
||||
repo = newTestRepository(db);
|
||||
species = newTestSpeciesRepository(db);
|
||||
});
|
||||
|
||||
tearDown(() => db.close());
|
||||
|
||||
Future<List<String>> pumpAndFire(WidgetTester tester, String payload) async {
|
||||
final opened = <String>[];
|
||||
await tester.pumpWidget(
|
||||
wrapScreen(
|
||||
repository: repo,
|
||||
child: _ScanHost(
|
||||
repository: repo,
|
||||
species: species,
|
||||
payload: payload,
|
||||
opened: opened,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('scanHost.fire')));
|
||||
await tester.pumpAndSettle();
|
||||
return opened;
|
||||
}
|
||||
|
||||
testWidgets('a known label opens its record straight away', (tester) async {
|
||||
final id = await repo.addQuickVariety(label: 'Tomate Rosa');
|
||||
final payload = SeedLabelCodec.encode(
|
||||
const SeedLabelData(varietyLabel: 'tomate rosa'),
|
||||
);
|
||||
final opened = await pumpAndFire(tester, payload);
|
||||
expect(opened, [id]);
|
||||
expect(find.byKey(const Key('scan.addPrompt')), findsNothing);
|
||||
await disposeTree(tester);
|
||||
});
|
||||
|
||||
testWidgets('an unknown label asks first, then adds and opens it', (
|
||||
tester,
|
||||
) async {
|
||||
final payload = SeedLabelCodec.encode(
|
||||
const SeedLabelData(
|
||||
varietyLabel: 'Acelga de Perales',
|
||||
year: 2024,
|
||||
origin: 'María',
|
||||
),
|
||||
);
|
||||
final opened = await pumpAndFire(tester, payload);
|
||||
expect(find.byKey(const Key('scan.addPrompt')), findsOneWidget);
|
||||
expect(opened, isEmpty); // nothing created before the person says so
|
||||
expect(await db.select(db.varieties).get(), isEmpty);
|
||||
|
||||
await tester.tap(find.byKey(const Key('scan.add')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final varieties = await db.select(db.varieties).get();
|
||||
expect(varieties.single.label, 'Acelga de Perales');
|
||||
final lots = await db.select(db.lots).get();
|
||||
expect(lots.single.harvestYear, 2024);
|
||||
expect(lots.single.originName, 'María');
|
||||
expect(opened, [varieties.single.id]);
|
||||
await disposeTree(tester);
|
||||
});
|
||||
|
||||
testWidgets('declining the prompt adds nothing', (tester) async {
|
||||
final payload = SeedLabelCodec.encode(
|
||||
const SeedLabelData(varietyLabel: 'Acelga de Perales'),
|
||||
);
|
||||
final opened = await pumpAndFire(tester, payload);
|
||||
await tester.tap(find.byKey(const Key('scan.cancel')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(await db.select(db.varieties).get(), isEmpty);
|
||||
expect(opened, isEmpty);
|
||||
await disposeTree(tester);
|
||||
});
|
||||
|
||||
testWidgets('a non-label QR just says so', (tester) async {
|
||||
final opened = await pumpAndFire(tester, 'https://example.org/whatever');
|
||||
expect(find.text('That code is not a seed label'), findsOneWidget);
|
||||
expect(opened, isEmpty);
|
||||
await disposeTree(tester);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue