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

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

View 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);
});
}