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
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