From ca23c07dca669e3d3a777fa2e7ce9724c5d2870d Mon Sep 17 00:00:00 2001 From: Christian Denker Date: Sun, 13 Sep 2026 09:48:35 +0200 Subject: [PATCH 1/3] feat: let apps plug a barcode scanner into form fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FormFieldProperties.enableBarcodeScanner` was parsed from the backend but no Widget ever acted on it, so a field configured for scanning in the web app did nothing in Flutter. Shipping a scanner in the package would mean native camera dependencies and a camera usage description for every app using `apptive_grid_form`, scanning or not. So the package defines the hole and the app fills it: `BarcodeScannerConfiguration` carries a `scan` callback and goes into `ApptiveGridOptions.formWidgetConfigurations`, the same way the Places API key already does. A scan button appears only where both sides agree — the app provides a scanner and the field asks for one — so nothing changes for apps that pass no configuration. Text fields put the scanned value into the field. Cross reference fields (single and multi, which share the picker) put it into the picker's search; when the query comes back with exactly one row, that row is selected right away rather than leaving a second tap to the user. The value is handed to the picker through a ValueNotifier so the selection grid can tell a scan apart from typing, and a scan is only acted on while the filter still holds the scanned text. The button's tooltip is part of the configuration rather than `ApptiveGridTranslation`: those translations are generated from POEditor by tool/update_translations.sh, so a string added locally would be dropped by the next run and break the build against the abstract base. Co-Authored-By: Claude Opus 5 --- packages/apptive_grid_form/CHANGELOG.md | 5 + packages/apptive_grid_form/README.md | 26 ++ .../barcode_scanner_configuration.dart | 55 +++++ .../form_widget_configurations.dart | 1 + .../widgets/apptive_grid_form_widgets.dart | 1 + .../form_widget/barcode_scan_button.dart | 70 ++++++ ..._reference_dropdown_button_form_field.dart | 56 +++++ .../cross_reference_form_widget.dart | 5 + .../src/widgets/form_widget/form_widget.dart | 9 +- .../multi_cross_reference_form_widget.dart | 5 + .../widgets/form_widget/text_form_widget.dart | 14 +- packages/apptive_grid_form/pubspec.yaml | 2 +- .../test/barcode_scanner_test.dart | 228 ++++++++++++++++++ 13 files changed, 472 insertions(+), 5 deletions(-) create mode 100644 packages/apptive_grid_form/lib/src/configurations/barcode_scanner_configuration.dart create mode 100644 packages/apptive_grid_form/lib/src/widgets/form_widget/barcode_scan_button.dart create mode 100644 packages/apptive_grid_form/test/barcode_scanner_test.dart diff --git a/packages/apptive_grid_form/CHANGELOG.md b/packages/apptive_grid_form/CHANGELOG.md index a68007b4..313ecc4e 100644 --- a/packages/apptive_grid_form/CHANGELOG.md +++ b/packages/apptive_grid_form/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.5.0 + + - **FEAT**: Fields whose `FormFieldProperties.enableBarcodeScanner` is set can offer a barcode or QR code scanner. The flag was already parsed from the backend but never acted on. Text and cross reference fields (single and multi) show a scan button, and in a cross reference picker a scan that matches exactly one row selects it right away instead of only filtering. + - **FEAT**: Add `BarcodeScannerConfiguration`. The package ships no scanner of its own – that would force native camera dependencies and a camera usage description on every app using it – so the app passes one through `ApptiveGridOptions.formWidgetConfigurations`, as it already does for the Places API key. Without it no scan button is shown anywhere, so nothing changes for apps that do not want one. + ## 2.4.1 - **FIX**: Form Widgets that bring their own layout no longer get a border from the embedding app. `CheckBoxFormWidget`, `AttachmentFormWidget`, `GeolocationFormWidget`, `AddressFormWidget`, `EnumFormWidget`, `EnumCollectionFormWidget` and the date and time fields inside `DateTimeFormWidget` only set `InputDecoration.border`, but an `InputDecorator` picks `enabledBorder`, `disabledBorder`, `focusedBorder`, `errorBorder` or `focusedErrorBorder` depending on the field's state and falls back to `border` only where the matching one is `null`. Since those are filled from the app's `InputDecorationTheme`, an app that styles the individual states – a line in the resting state, for example – drew a box around a checkbox or an option list. All five are now set. diff --git a/packages/apptive_grid_form/README.md b/packages/apptive_grid_form/README.md index 23d9e659..35e16511 100644 --- a/packages/apptive_grid_form/README.md +++ b/packages/apptive_grid_form/README.md @@ -161,6 +161,32 @@ Widget build(BuildContext context) { } ``` +### Barcode and QR Code Scanner + +A field whose `enableBarcodeScanner` is set in its `FormFieldProperties` can offer a scan button. The package brings no scanner itself: a scanner plugin means native camera dependencies and a camera usage description for every app using `apptive_grid_form`, whether it scans or not. Pass one in instead: + +```dart +ApptiveGrid( + options: ApptiveGridOptions( + formWidgetConfigurations: [ + BarcodeScannerConfiguration( + scan: (context) => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const MyScannerPage()), + ), + buttonTooltip: 'Scan barcode', + ), + ], + ), + child: ..., +) +``` + +`scan` completes with the scanned value, or `null` when the user cancels. Both sides have to agree before a button appears: the app provides the configuration **and** the field asks for it. Without the configuration no button is shown anywhere. + +Text fields put the scanned value into the field. Cross reference fields put it into the picker's search, and when exactly one row matches it is selected right away. + +`buttonTooltip` lives in the configuration rather than in the package's translations because those are generated from POEditor — a string added locally would be dropped by the next update. Pass a localized label from the app. + ### Label Position By default a field's label is a Material floating label: it sits inside the field and moves to its top edge once the field is focused or filled. Set `labelPosition` to `ApptiveGridLabelPosition.above` to render the label as a separate `Text` above the field instead. The field itself is then rendered without a label, which means a field's placeholder is visible while it is empty. diff --git a/packages/apptive_grid_form/lib/src/configurations/barcode_scanner_configuration.dart b/packages/apptive_grid_form/lib/src/configurations/barcode_scanner_configuration.dart new file mode 100644 index 00000000..fd961390 --- /dev/null +++ b/packages/apptive_grid_form/lib/src/configurations/barcode_scanner_configuration.dart @@ -0,0 +1,55 @@ +import 'package:apptive_grid_form/apptive_grid_form.dart'; +import 'package:flutter/widgets.dart'; + +/// [FormWidgetConfiguration] that lets Form Widgets offer a barcode or QR code +/// scanner +/// +/// The package deliberately ships no scanner itself: a scanner plugin pulls in +/// native camera dependencies and a camera usage description, which every app +/// using [ApptiveGridForm] would have to carry whether or not it scans +/// anything. Providing this configuration is what turns the feature on. +/// +/// ```dart +/// ApptiveGrid( +/// options: ApptiveGridOptions( +/// formWidgetConfigurations: [ +/// BarcodeScannerConfiguration( +/// scan: (context) => Navigator.of(context).push( +/// MaterialPageRoute(builder: (_) => const MyScannerPage()), +/// ), +/// buttonTooltip: 'Scan barcode', +/// ), +/// ], +/// ), +/// child: ..., +/// ) +/// ``` +/// +/// A scan button is only shown where the field's +/// [FormFieldProperties.enableBarcodeScanner] is set as well, so the form's +/// configuration stays in charge of which fields offer one. +class BarcodeScannerConfiguration extends FormWidgetConfiguration { + /// Creates a new [BarcodeScannerConfiguration] + const BarcodeScannerConfiguration({ + required this.scan, + this.buttonTooltip, + }); + + /// Opens the app's scanner and completes with the scanned value + /// + /// Complete with `null` when the user cancels or nothing was scanned; the + /// field is left untouched in that case. + final Future Function(BuildContext context) scan; + + /// Tooltip of the scan button + /// + /// This lives here rather than in [ApptiveGridTranslation] because the + /// package's translations are generated from POEditor: a string added to + /// them locally would be dropped by the next update. Pass a localized label + /// from the app. + final String? buttonTooltip; + + @override + String toString() => + 'BarcodeScannerConfiguration(buttonTooltip: $buttonTooltip)'; +} diff --git a/packages/apptive_grid_form/lib/src/configurations/form_widget_configurations.dart b/packages/apptive_grid_form/lib/src/configurations/form_widget_configurations.dart index 1962cf0d..0aa2811b 100644 --- a/packages/apptive_grid_form/lib/src/configurations/form_widget_configurations.dart +++ b/packages/apptive_grid_form/lib/src/configurations/form_widget_configurations.dart @@ -1,2 +1,3 @@ +export 'barcode_scanner_configuration.dart'; export 'geolocation_form_widget_configuration.dart'; export 'label_configuration.dart'; diff --git a/packages/apptive_grid_form/lib/src/widgets/apptive_grid_form_widgets.dart b/packages/apptive_grid_form/lib/src/widgets/apptive_grid_form_widgets.dart index d8cc2dce..24e0b14a 100644 --- a/packages/apptive_grid_form/lib/src/widgets/apptive_grid_form_widgets.dart +++ b/packages/apptive_grid_form/lib/src/widgets/apptive_grid_form_widgets.dart @@ -1,6 +1,7 @@ export 'package:apptive_grid_form/src/widgets/actions/action_button.dart'; export 'package:apptive_grid_form/src/widgets/form_widget/address_form_widget.dart'; export 'package:apptive_grid_form/src/widgets/form_widget/attachment_form_widget.dart'; +export 'package:apptive_grid_form/src/widgets/form_widget/barcode_scan_button.dart'; export 'package:apptive_grid_form/src/widgets/form_widget/check_box_form_widget.dart'; export 'package:apptive_grid_form/src/widgets/form_widget/cross_reference_form_widget.dart'; export 'package:apptive_grid_form/src/widgets/form_widget/currency_form_widget.dart'; diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/barcode_scan_button.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/barcode_scan_button.dart new file mode 100644 index 00000000..7b926a4f --- /dev/null +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/barcode_scan_button.dart @@ -0,0 +1,70 @@ +import 'package:apptive_grid_form/apptive_grid_form.dart'; +import 'package:flutter/material.dart'; + +/// A button that scans a barcode into a field, or `null` where no scanner +/// should be offered +/// +/// Returns `null` unless both sides agree: the app provides a +/// [BarcodeScannerConfiguration] and the field's +/// [FormFieldProperties.enableBarcodeScanner] is set. Being `null` rather than +/// an empty Widget lets it be passed to [InputDecoration.suffixIcon] directly, +/// where a placeholder would still take up space. +Widget? barcodeScanButton( + BuildContext context, { + required FormFieldProperties? fieldProperties, + required bool enabled, + required ValueChanged onScanned, +}) { + if (fieldProperties?.enableBarcodeScanner != true) { + return null; + } + final configuration = ApptiveGrid.getOptions(context) + .formWidgetConfigurations + .whereType() + .firstOrNull; + if (configuration == null) { + return null; + } + return BarcodeScanButton( + configuration: configuration, + enabled: enabled, + onScanned: onScanned, + ); +} + +/// The button [barcodeScanButton] builds +class BarcodeScanButton extends StatelessWidget { + /// Creates a new [BarcodeScanButton] + const BarcodeScanButton({ + super.key, + required this.configuration, + required this.enabled, + required this.onScanned, + }); + + /// The scanner provided by the app + final BarcodeScannerConfiguration configuration; + + /// Whether the field this belongs to can be edited + final bool enabled; + + /// Called with the scanned value, never with `null` + final ValueChanged onScanned; + + @override + Widget build(BuildContext context) { + return IconButton( + key: const Key('BarcodeScanButton'), + tooltip: configuration.buttonTooltip, + icon: const Icon(Icons.qr_code_scanner), + onPressed: enabled + ? () async { + final scanned = await configuration.scan(context); + if (scanned != null && scanned.isNotEmpty) { + onScanned(scanned); + } + } + : null, + ); + } +} diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/cross_reference/cross_reference_dropdown_button_form_field.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/cross_reference/cross_reference_dropdown_button_form_field.dart index 7b406bab..4ee2621f 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/cross_reference/cross_reference_dropdown_button_form_field.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/cross_reference/cross_reference_dropdown_button_form_field.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:math'; import 'package:apptive_grid_form/apptive_grid_form.dart'; +import 'package:apptive_grid_form/src/widgets/form_widget/barcode_scan_button.dart'; import 'package:apptive_grid_form/src/translation/apptive_grid_localization.dart'; import 'package:apptive_grid_form/src/widgets/form_widget/form_widget_helpers.dart'; import 'package:apptive_grid_form/src/widgets/grid/grid_row.dart'; @@ -18,6 +19,7 @@ class CrossReferenceDropdownButtonFormField required this.component, required this.selectedItemBuilder, required this.onSelected, + this.fieldProperties, required this.selectedNotifier, }) : assert( T == CrossReferenceDataEntity || T == MultiCrossReferenceDataEntity, @@ -36,6 +38,9 @@ class CrossReferenceDropdownButtonFormField CrossReferenceDropdownButtonFormFieldState state, ) onSelected; + /// Properties of the component's field, used for the barcode scanner + final FormFieldProperties? fieldProperties; + /// A [Notifier] to notify when the selected entity changed final SelectedRowsNotifier selectedNotifier; @@ -61,6 +66,13 @@ class CrossReferenceDropdownButtonFormFieldState final _filterController = TextEditingController(); + /// The value of the last barcode scan, consumed by the selection grid + /// + /// Held here rather than written straight into [_filterController] so the + /// grid can tell a scan apart from typing and select a unique match on its + /// own. + final _scannedValue = ValueNotifier(null); + late final Uri _gridUri; final _overlayKey = GlobalKey(); @@ -193,6 +205,12 @@ class CrossReferenceDropdownButtonFormFieldState Icons.search, color: Theme.of(context).textTheme.displayLarge?.color, ), + suffixIcon: barcodeScanButton( + context, + fieldProperties: widget.fieldProperties, + enabled: widget.component.enabled, + onScanned: (value) => _scannedValue.value = value, + ), hintText: localization.crossRefSearch, isDense: true, ), @@ -216,6 +234,7 @@ class CrossReferenceDropdownButtonFormFieldState enabled: false, child: _CrossReferenceSelectionGrid( controller: _filterController, + scannedValue: _scannedValue, scrollControllerGroup: _scrollControllerGroup, grid: _grid!, selectedNotifier: widget.selectedNotifier, @@ -268,6 +287,7 @@ class CrossReferenceDropdownButtonFormFieldState class _CrossReferenceSelectionGrid extends StatefulWidget { const _CrossReferenceSelectionGrid({ required this.controller, + required this.scannedValue, required this.scrollControllerGroup, required this.grid, required this.selectedNotifier, @@ -275,6 +295,7 @@ class _CrossReferenceSelectionGrid extends StatefulWidget { }); final TextEditingController controller; + final ValueNotifier scannedValue; final LinkedScrollControllerGroup scrollControllerGroup; final Grid grid; final SelectedRowsNotifier selectedNotifier; @@ -296,6 +317,38 @@ class _CrossReferenceSelectionGridState void initState() { super.initState(); widget.controller.addListener(_loadRows); + widget.scannedValue.addListener(_onScanned); + } + + /// Puts a scanned value into the filter so the query runs against it + /// + /// Scanning the same code twice leaves the text untouched, which would not + /// notify the filter listener, so the reload is triggered directly then. + void _onScanned() { + final scanned = widget.scannedValue.value; + if (scanned == null) { + return; + } + if (widget.controller.text == scanned) { + _loadRows(); + } else { + widget.controller.text = scanned; + } + } + + /// Selects the row a scan unambiguously points at + /// + /// Only for the load that belongs to the scan: if the user kept typing in + /// the meantime, the result is theirs to pick. + void _selectScannedMatch(List? rows) { + final scanned = widget.scannedValue.value; + if (scanned == null || scanned != widget.controller.text) { + return; + } + widget.scannedValue.value = null; + if (rows != null && rows.length == 1) { + widget.onSelected(rows.first, true); + } } @override @@ -310,6 +363,7 @@ class _CrossReferenceSelectionGridState @override void dispose() { widget.controller.removeListener(_loadRows); + widget.scannedValue.removeListener(_onScanned); for (final controller in _scrollControllers.values) { controller.dispose(); } @@ -408,12 +462,14 @@ class _CrossReferenceSelectionGridState setState(() { _rows = rows; }); + _selectScannedMatch(rows); } } else { if (mounted) { setState(() { _rows = []; }); + _selectScannedMatch(const []); } } } diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/cross_reference_form_widget.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/cross_reference_form_widget.dart index 2a363558..fa5fcae3 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/cross_reference_form_widget.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/cross_reference_form_widget.dart @@ -8,11 +8,15 @@ class CrossReferenceFormWidget extends StatefulWidget { const CrossReferenceFormWidget({ super.key, required this.component, + this.fieldProperties, }); /// Component this Widget should reflect final FormComponent component; + /// Properties of the component's field, used for the barcode scanner + final FormFieldProperties? fieldProperties; + @override State createState() => _CrossReferenceFormWidgetState(); @@ -31,6 +35,7 @@ class _CrossReferenceFormWidgetState extends State { Widget build(BuildContext context) { return CrossReferenceDropdownButtonFormField( component: widget.component, + fieldProperties: widget.fieldProperties, selectedItemBuilder: (data) => Text( data?.value?.toString() ?? '', ), diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/form_widget.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/form_widget.dart index 4dd93360..9cdc225e 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/form_widget.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/form_widget.dart @@ -15,9 +15,10 @@ Widget fromModel( FormFieldProperties? properties, }) => switch (component.field.type) { - DataType.text || - DataType.richText => - TextFormWidget(component: component.cast()), + DataType.text || DataType.richText => TextFormWidget( + component: component.cast(), + fieldProperties: properties, + ), DataType.dateTime => DateTimeFormWidget( component: component.cast(), ), @@ -33,6 +34,7 @@ Widget fromModel( EnumFormWidget(component: component.cast()), DataType.crossReference => CrossReferenceFormWidget( component: component.cast(), + fieldProperties: properties, ), DataType.attachment => AttachmentFormWidget( component: component.cast(), @@ -50,6 +52,7 @@ Widget fromModel( ), DataType.multiCrossReference => MultiCrossReferenceFormWidget( component: component.cast(), + fieldProperties: properties, ), DataType.user => UserFormWidget(component: component.cast()), diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/multi_cross_reference_form_widget.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/multi_cross_reference_form_widget.dart index e2fec1d7..6c0f8f52 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/multi_cross_reference_form_widget.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/multi_cross_reference_form_widget.dart @@ -8,11 +8,15 @@ class MultiCrossReferenceFormWidget extends StatefulWidget { const MultiCrossReferenceFormWidget({ super.key, required this.component, + this.fieldProperties, }); /// Component this Widget should reflect final FormComponent component; + /// Properties of the component's field, used for the barcode scanner + final FormFieldProperties? fieldProperties; + @override State createState() => _MultiCrossReferenceFormWidgetState(); @@ -34,6 +38,7 @@ class _MultiCrossReferenceFormWidgetState Widget build(BuildContext context) { return CrossReferenceDropdownButtonFormField( component: widget.component, + fieldProperties: widget.fieldProperties, selectedItemBuilder: (data) => Text( data!.value!.map((e) => e.value ?? '').join(', '), ), diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/text_form_widget.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/text_form_widget.dart index 9547c66f..b85649c1 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/text_form_widget.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/text_form_widget.dart @@ -1,5 +1,6 @@ import 'package:apptive_grid_form/apptive_grid_form.dart'; import 'package:apptive_grid_form/src/translation/apptive_grid_localization.dart'; +import 'package:apptive_grid_form/src/widgets/form_widget/barcode_scan_button.dart'; import 'package:apptive_grid_form/src/widgets/form_widget/form_widget_helpers.dart'; import 'package:flutter/material.dart'; @@ -9,11 +10,15 @@ class TextFormWidget extends StatefulWidget { const TextFormWidget({ super.key, required this.component, + this.fieldProperties, }); /// Component this Widget should reflect final FormComponent component; + /// Properties of the component's field, used for the barcode scanner + final FormFieldProperties? fieldProperties; + @override State createState() => _TextFormWidgetState(); } @@ -56,7 +61,14 @@ class _TextFormWidgetState extends State autovalidateMode: AutovalidateMode.onUserInteraction, minLines: widget.component.options.multi ? 3 : 1, maxLines: widget.component.options.multi ? null : 1, - decoration: widget.component.baseDecoration(context), + decoration: widget.component.baseDecoration(context).copyWith( + suffixIcon: barcodeScanButton( + context, + fieldProperties: widget.fieldProperties, + enabled: widget.component.enabled, + onScanned: (value) => _controller.text = value, + ), + ), enabled: widget.component.enabled, ); } diff --git a/packages/apptive_grid_form/pubspec.yaml b/packages/apptive_grid_form/pubspec.yaml index 303d425a..1926524b 100644 --- a/packages/apptive_grid_form/pubspec.yaml +++ b/packages/apptive_grid_form/pubspec.yaml @@ -1,6 +1,6 @@ name: apptive_grid_form description: A Flutter Package to display ApptiveGrid Forms inside a Flutter App. -version: 2.4.1 +version: 2.5.0 homepage: https://www.apptivegrid.de repository: https://github.com/ApptiveGrid/apptive_grid_flutter/tree/main/packages/apptive_grid_form diff --git a/packages/apptive_grid_form/test/barcode_scanner_test.dart b/packages/apptive_grid_form/test/barcode_scanner_test.dart new file mode 100644 index 00000000..1016a34c --- /dev/null +++ b/packages/apptive_grid_form/test/barcode_scanner_test.dart @@ -0,0 +1,228 @@ +import 'dart:convert'; + +import 'package:apptive_grid_form/apptive_grid_form.dart'; +import 'package:apptive_grid_form/src/widgets/apptive_grid_form_widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'common.dart'; + +final _scanButton = find.byKey(const Key('BarcodeScanButton')); + +BarcodeScannerConfiguration _scanner(String value, {List? log}) => + BarcodeScannerConfiguration( + scan: (_) async { + log?.add(value); + return value; + }, + buttonTooltip: 'Scan', + ); + +FormFieldProperties _properties({required bool enableBarcodeScanner}) => + FormFieldProperties( + fieldId: 'fieldId', + enableBarcodeScanner: enableBarcodeScanner, + ); + +void main() { + setUpAll(() { + registerFallbackValue( + FormData(id: 'id', title: '', components: [], fields: [], links: {}), + ); + registerFallbackValue(Uri.parse('/api/users/user/spaces/space/grids/grid')); + }); + + group('Text field', () { + const field = GridField(id: 'fieldId', name: 'name', type: DataType.text); + + Future pumpText( + WidgetTester tester, { + required bool enableBarcodeScanner, + BarcodeScannerConfiguration? configuration, + bool enabled = true, + }) async { + await tester.pumpWidget( + TestApp( + options: ApptiveGridOptions( + formWidgetConfigurations: [ + if (configuration != null) configuration, + ], + ), + child: TextFormWidget( + component: FormComponent( + property: 'Property', + data: StringDataEntity(), + field: field, + enabled: enabled, + ), + fieldProperties: + _properties(enableBarcodeScanner: enableBarcodeScanner), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('No button without a scanner from the app', (tester) async { + // The field asks for a scanner, but nothing can serve it + await pumpText(tester, enableBarcodeScanner: true); + expect(_scanButton, findsNothing); + }); + + testWidgets('No button where the field did not ask for one', + (tester) async { + await pumpText( + tester, + enableBarcodeScanner: false, + configuration: _scanner('anything'), + ); + expect(_scanButton, findsNothing); + }); + + testWidgets('A scan fills the field', (tester) async { + await pumpText( + tester, + enableBarcodeScanner: true, + configuration: _scanner('4006381333931'), + ); + expect(_scanButton, findsOneWidget); + + await tester.tap(_scanButton); + await tester.pumpAndSettle(); + + expect(find.text('4006381333931'), findsOneWidget); + }); + + testWidgets('A disabled field cannot be scanned into', (tester) async { + await pumpText( + tester, + enableBarcodeScanner: true, + configuration: _scanner('4006381333931'), + enabled: false, + ); + + expect(tester.widget(_scanButton).onPressed, isNull); + }); + }); + + group('Cross reference picker', () { + final gridUri = Uri.parse('/api/users/user/spaces/space/grids/grid'); + const crossRefField = + GridField(id: 'fieldId', name: 'name', type: DataType.crossReference); + const nameField = GridField(id: 'field', name: 'Name', type: DataType.text); + + final queryLink = ApptiveLink( + uri: gridUri.replace(path: '${gridUri.path}/query'), + method: 'get', + ); + + final grid = Grid( + id: 'grid', + name: 'Test', + fields: [nameField], + rows: [], + links: { + ApptiveLinkType.self: ApptiveLink(uri: gridUri, method: 'get'), + ApptiveLinkType.query: queryLink, + }, + ); + + Map entity(String id, String name) => { + '_id': id, + 'fields': [name], + '_links': { + 'self': {'href': '/api/entities/$id', 'method': 'get'}, + }, + }; + + late ApptiveGridClient client; + + setUp(() { + client = MockApptiveGridClient(); + when(() => client.sendPendingActions()).thenAnswer((_) async => []); + when(() => client.loadGrid(uri: any(named: 'uri'), loadEntities: false)) + .thenAnswer((_) async => grid); + when( + () => client.performApptiveLink>( + link: queryLink, + queryParameters: any(named: 'queryParameters'), + parseResponse: any(named: 'parseResponse'), + ), + ).thenAnswer((invocation) async { + final query = invocation.namedArguments[const Symbol('queryParameters')] + ['matching'] as String?; + final parseResponse = + invocation.namedArguments[const Symbol('parseResponse')] + as Future?> Function(Response); + final entities = query == null || query.isEmpty + ? [entity('row1', 'First'), entity('row2', 'Second')] + // A scanned code matches exactly one row + : [entity('row2', 'Second')]; + return parseResponse( + Response(jsonEncode({'entities': entities}), 200), + ); + }); + }); + + Future pumpPicker( + WidgetTester tester, { + required bool enableBarcodeScanner, + BarcodeScannerConfiguration? configuration, + }) async { + // ApptiveGrid.getOptions reads them off the client, so the mock has to + // carry them rather than TestApp + when(() => client.options).thenReturn( + ApptiveGridOptions( + formWidgetConfigurations: [ + if (configuration != null) configuration, + ], + ), + ); + await tester.pumpWidget( + TestApp( + client: client, + child: CrossReferenceFormWidget( + component: FormComponent( + property: 'Property', + data: CrossReferenceDataEntity(gridUri: gridUri), + field: crossRefField, + ), + fieldProperties: + _properties(enableBarcodeScanner: enableBarcodeScanner), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.arrow_drop_down)); + await tester.pumpAndSettle(); + } + + testWidgets('No button without a scanner from the app', (tester) async { + await pumpPicker(tester, enableBarcodeScanner: true); + expect(_scanButton, findsNothing); + expect(find.text('First'), findsOneWidget); + }); + + testWidgets('A scan selects the row it unambiguously points at', + (tester) async { + final scanned = []; + await pumpPicker( + tester, + enableBarcodeScanner: true, + configuration: _scanner('Second', log: scanned), + ); + expect(_scanButton, findsOneWidget); + expect(find.text('First'), findsOneWidget); + + await tester.tap(_scanButton); + await tester.pumpAndSettle(); + + expect(scanned, equals(['Second'])); + // The overlay closed on the match, leaving the picked value behind + expect(find.text('First'), findsNothing); + expect(find.text('Second'), findsOneWidget); + }); + }); +} From 037504821c41546f4665d240aeccdc92ca3ec2ba Mon Sep 17 00:00:00 2001 From: Christian Denker Date: Sun, 13 Sep 2026 10:03:36 +0200 Subject: [PATCH 2/3] fix: offer the scanner on every cross reference field enableBarcodeScanner is a text field setting; the backend never sets it on a cross reference field, so gating the picker's scan button on it meant the button never appeared there. The condition now differs by field: a text field still needs the flag, a cross reference field shows the button whenever the app configured a scanner. With that the field's FormFieldProperties are no longer needed in either cross reference Widget, so the plumbing goes away again. Reading the options while building the picker turns out to throw against a bare mocked ApptiveGridClient, which answers `null` where the type says it cannot. That is what every cross reference test in this package did, so MockApptiveGridClient carries real options now instead of stubbing the getter. Consumers mocking the client in widget tests that render a cross reference field need the same. Co-Authored-By: Claude Opus 5 --- packages/apptive_grid_form/CHANGELOG.md | 3 ++- packages/apptive_grid_form/README.md | 11 +++++++-- .../barcode_scanner_configuration.dart | 8 ++++--- .../form_widget/barcode_scan_button.dart | 13 +++++----- ..._reference_dropdown_button_form_field.dart | 9 ++++--- .../cross_reference_form_widget.dart | 5 ---- .../src/widgets/form_widget/form_widget.dart | 2 -- .../multi_cross_reference_form_widget.dart | 5 ---- .../widgets/form_widget/text_form_widget.dart | 3 ++- .../test/apptive_grid_form_test.dart | 6 ++--- .../test/barcode_scanner_test.dart | 24 ++++++++++--------- packages/apptive_grid_form/test/common.dart | 14 +++++++++-- .../test/geolocation_form_widget_test.dart | 2 +- 13 files changed, 57 insertions(+), 48 deletions(-) diff --git a/packages/apptive_grid_form/CHANGELOG.md b/packages/apptive_grid_form/CHANGELOG.md index 313ecc4e..9ff236ed 100644 --- a/packages/apptive_grid_form/CHANGELOG.md +++ b/packages/apptive_grid_form/CHANGELOG.md @@ -1,6 +1,7 @@ ## 2.5.0 - - **FEAT**: Fields whose `FormFieldProperties.enableBarcodeScanner` is set can offer a barcode or QR code scanner. The flag was already parsed from the backend but never acted on. Text and cross reference fields (single and multi) show a scan button, and in a cross reference picker a scan that matches exactly one row selects it right away instead of only filtering. + - **FEAT**: Text and cross reference fields can offer a barcode or QR code scanner. A text field shows a scan button where its `FormFieldProperties.enableBarcodeScanner` is set – a flag the backend already sent but no Widget acted on. A cross reference field shows one whenever a scanner is available, since that flag is a text field setting and is never set on them; a scan there searches the picker and selects the row right away when exactly one matches, instead of only filtering. + - **NOTE**: The cross reference picker now reads `ApptiveGridOptions` while building, to find out whether a scanner is available. A widget test that renders one against a mocked `ApptiveGridClient` has to answer its `options`, which a bare mock leaves as `null`. - **FEAT**: Add `BarcodeScannerConfiguration`. The package ships no scanner of its own – that would force native camera dependencies and a camera usage description on every app using it – so the app passes one through `ApptiveGridOptions.formWidgetConfigurations`, as it already does for the Places API key. Without it no scan button is shown anywhere, so nothing changes for apps that do not want one. ## 2.4.1 diff --git a/packages/apptive_grid_form/README.md b/packages/apptive_grid_form/README.md index 35e16511..7a48fdae 100644 --- a/packages/apptive_grid_form/README.md +++ b/packages/apptive_grid_form/README.md @@ -181,9 +181,16 @@ ApptiveGrid( ) ``` -`scan` completes with the scanned value, or `null` when the user cancels. Both sides have to agree before a button appears: the app provides the configuration **and** the field asks for it. Without the configuration no button is shown anywhere. +`scan` completes with the scanned value, or `null` when the user cancels. Without the configuration no button is shown anywhere. -Text fields put the scanned value into the field. Cross reference fields put it into the picker's search, and when exactly one row matches it is selected right away. +Where a button appears differs by field: + +| Field | Shows a scan button | On a scan | +| --- | --- | --- | +| Text | where `enableBarcodeScanner` is set on the field | the value goes into the field | +| Cross reference (single and multi) | whenever a scanner is configured | the picker searches for it and selects the row when exactly one matches | + +A cross reference field does not wait for `enableBarcodeScanner`: that flag is a text field setting and the backend never sets it on them. `buttonTooltip` lives in the configuration rather than in the package's translations because those are generated from POEditor — a string added locally would be dropped by the next update. Pass a localized label from the app. diff --git a/packages/apptive_grid_form/lib/src/configurations/barcode_scanner_configuration.dart b/packages/apptive_grid_form/lib/src/configurations/barcode_scanner_configuration.dart index fd961390..04e07733 100644 --- a/packages/apptive_grid_form/lib/src/configurations/barcode_scanner_configuration.dart +++ b/packages/apptive_grid_form/lib/src/configurations/barcode_scanner_configuration.dart @@ -25,9 +25,11 @@ import 'package:flutter/widgets.dart'; /// ) /// ``` /// -/// A scan button is only shown where the field's -/// [FormFieldProperties.enableBarcodeScanner] is set as well, so the form's -/// configuration stays in charge of which fields offer one. +/// Where a button then appears differs by field: a text field also needs its +/// [FormFieldProperties.enableBarcodeScanner] set, so the form's configuration +/// stays in charge there. A cross reference field shows one whenever a scanner +/// is available, because that flag is a text field setting the backend never +/// sets on them. class BarcodeScannerConfiguration extends FormWidgetConfiguration { /// Creates a new [BarcodeScannerConfiguration] const BarcodeScannerConfiguration({ diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/barcode_scan_button.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/barcode_scan_button.dart index 7b926a4f..28cdff66 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/barcode_scan_button.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/barcode_scan_button.dart @@ -4,18 +4,17 @@ import 'package:flutter/material.dart'; /// A button that scans a barcode into a field, or `null` where no scanner /// should be offered /// -/// Returns `null` unless both sides agree: the app provides a -/// [BarcodeScannerConfiguration] and the field's -/// [FormFieldProperties.enableBarcodeScanner] is set. Being `null` rather than -/// an empty Widget lets it be passed to [InputDecoration.suffixIcon] directly, -/// where a placeholder would still take up space. +/// Returns `null` unless the app provides a [BarcodeScannerConfiguration] and +/// [fieldAsksForScanner] holds. Being `null` rather than an empty Widget lets +/// it be passed to [InputDecoration.suffixIcon] directly, where a placeholder +/// would still take up space. Widget? barcodeScanButton( BuildContext context, { - required FormFieldProperties? fieldProperties, + required bool fieldAsksForScanner, required bool enabled, required ValueChanged onScanned, }) { - if (fieldProperties?.enableBarcodeScanner != true) { + if (!fieldAsksForScanner) { return null; } final configuration = ApptiveGrid.getOptions(context) diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/cross_reference/cross_reference_dropdown_button_form_field.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/cross_reference/cross_reference_dropdown_button_form_field.dart index 4ee2621f..d6fc2f60 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/cross_reference/cross_reference_dropdown_button_form_field.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/cross_reference/cross_reference_dropdown_button_form_field.dart @@ -19,7 +19,6 @@ class CrossReferenceDropdownButtonFormField required this.component, required this.selectedItemBuilder, required this.onSelected, - this.fieldProperties, required this.selectedNotifier, }) : assert( T == CrossReferenceDataEntity || T == MultiCrossReferenceDataEntity, @@ -38,9 +37,6 @@ class CrossReferenceDropdownButtonFormField CrossReferenceDropdownButtonFormFieldState state, ) onSelected; - /// Properties of the component's field, used for the barcode scanner - final FormFieldProperties? fieldProperties; - /// A [Notifier] to notify when the selected entity changed final SelectedRowsNotifier selectedNotifier; @@ -207,7 +203,10 @@ class CrossReferenceDropdownButtonFormFieldState ), suffixIcon: barcodeScanButton( context, - fieldProperties: widget.fieldProperties, + // The backend's enableBarcodeScanner is a text field setting + // and is never set on a cross reference field, so here the app + // providing a scanner is the whole condition. + fieldAsksForScanner: true, enabled: widget.component.enabled, onScanned: (value) => _scannedValue.value = value, ), diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/cross_reference_form_widget.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/cross_reference_form_widget.dart index fa5fcae3..2a363558 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/cross_reference_form_widget.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/cross_reference_form_widget.dart @@ -8,15 +8,11 @@ class CrossReferenceFormWidget extends StatefulWidget { const CrossReferenceFormWidget({ super.key, required this.component, - this.fieldProperties, }); /// Component this Widget should reflect final FormComponent component; - /// Properties of the component's field, used for the barcode scanner - final FormFieldProperties? fieldProperties; - @override State createState() => _CrossReferenceFormWidgetState(); @@ -35,7 +31,6 @@ class _CrossReferenceFormWidgetState extends State { Widget build(BuildContext context) { return CrossReferenceDropdownButtonFormField( component: widget.component, - fieldProperties: widget.fieldProperties, selectedItemBuilder: (data) => Text( data?.value?.toString() ?? '', ), diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/form_widget.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/form_widget.dart index 9cdc225e..fe0a8ee3 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/form_widget.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/form_widget.dart @@ -34,7 +34,6 @@ Widget fromModel( EnumFormWidget(component: component.cast()), DataType.crossReference => CrossReferenceFormWidget( component: component.cast(), - fieldProperties: properties, ), DataType.attachment => AttachmentFormWidget( component: component.cast(), @@ -52,7 +51,6 @@ Widget fromModel( ), DataType.multiCrossReference => MultiCrossReferenceFormWidget( component: component.cast(), - fieldProperties: properties, ), DataType.user => UserFormWidget(component: component.cast()), diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/multi_cross_reference_form_widget.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/multi_cross_reference_form_widget.dart index 6c0f8f52..e2fec1d7 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/multi_cross_reference_form_widget.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/multi_cross_reference_form_widget.dart @@ -8,15 +8,11 @@ class MultiCrossReferenceFormWidget extends StatefulWidget { const MultiCrossReferenceFormWidget({ super.key, required this.component, - this.fieldProperties, }); /// Component this Widget should reflect final FormComponent component; - /// Properties of the component's field, used for the barcode scanner - final FormFieldProperties? fieldProperties; - @override State createState() => _MultiCrossReferenceFormWidgetState(); @@ -38,7 +34,6 @@ class _MultiCrossReferenceFormWidgetState Widget build(BuildContext context) { return CrossReferenceDropdownButtonFormField( component: widget.component, - fieldProperties: widget.fieldProperties, selectedItemBuilder: (data) => Text( data!.value!.map((e) => e.value ?? '').join(', '), ), diff --git a/packages/apptive_grid_form/lib/src/widgets/form_widget/text_form_widget.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/text_form_widget.dart index b85649c1..7fe68e89 100644 --- a/packages/apptive_grid_form/lib/src/widgets/form_widget/text_form_widget.dart +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/text_form_widget.dart @@ -64,7 +64,8 @@ class _TextFormWidgetState extends State decoration: widget.component.baseDecoration(context).copyWith( suffixIcon: barcodeScanButton( context, - fieldProperties: widget.fieldProperties, + fieldAsksForScanner: + widget.fieldProperties?.enableBarcodeScanner == true, enabled: widget.component.enabled, onScanned: (value) => _controller.text = value, ), diff --git a/packages/apptive_grid_form/test/apptive_grid_form_test.dart b/packages/apptive_grid_form/test/apptive_grid_form_test.dart index d1e53f50..0a444605 100644 --- a/packages/apptive_grid_form/test/apptive_grid_form_test.dart +++ b/packages/apptive_grid_form/test/apptive_grid_form_test.dart @@ -667,8 +667,8 @@ void main() { ); when(() => cache.getPendingActionItems()) .thenAnswer((invocation) => cacheMap.toList()); - when(() => client.options) - .thenAnswer((invocation) => ApptiveGridOptions(cache: cache)); + (client as MockApptiveGridClient).options = + ApptiveGridOptions(cache: cache); final target = TestApp( client: client, child: ApptiveGridForm( @@ -1113,7 +1113,7 @@ void main() { AttachmentCompleteProgressEvent(http.Response('', 400)), ), ); - when(() => client.options).thenReturn(ApptiveGridOptions(cache: cache)); + client.options = ApptiveGridOptions(cache: cache); final target = TestApp( client: client, diff --git a/packages/apptive_grid_form/test/barcode_scanner_test.dart b/packages/apptive_grid_form/test/barcode_scanner_test.dart index 1016a34c..21d1f89b 100644 --- a/packages/apptive_grid_form/test/barcode_scanner_test.dart +++ b/packages/apptive_grid_form/test/barcode_scanner_test.dart @@ -168,17 +168,14 @@ void main() { Future pumpPicker( WidgetTester tester, { - required bool enableBarcodeScanner, BarcodeScannerConfiguration? configuration, }) async { // ApptiveGrid.getOptions reads them off the client, so the mock has to // carry them rather than TestApp - when(() => client.options).thenReturn( - ApptiveGridOptions( - formWidgetConfigurations: [ - if (configuration != null) configuration, - ], - ), + (client as MockApptiveGridClient).options = ApptiveGridOptions( + formWidgetConfigurations: [ + if (configuration != null) configuration, + ], ); await tester.pumpWidget( TestApp( @@ -189,8 +186,6 @@ void main() { data: CrossReferenceDataEntity(gridUri: gridUri), field: crossRefField, ), - fieldProperties: - _properties(enableBarcodeScanner: enableBarcodeScanner), ), ), ); @@ -200,17 +195,24 @@ void main() { } testWidgets('No button without a scanner from the app', (tester) async { - await pumpPicker(tester, enableBarcodeScanner: true); + await pumpPicker(tester); expect(_scanButton, findsNothing); expect(find.text('First'), findsOneWidget); }); + testWidgets('The button does not wait for enableBarcodeScanner', + (tester) async { + // Unlike a text field: the backend never sets that flag on a cross + // reference field, so the app providing a scanner is the whole condition + await pumpPicker(tester, configuration: _scanner('Second')); + expect(_scanButton, findsOneWidget); + }); + testWidgets('A scan selects the row it unambiguously points at', (tester) async { final scanned = []; await pumpPicker( tester, - enableBarcodeScanner: true, configuration: _scanner('Second', log: scanned), ); expect(_scanButton, findsOneWidget); diff --git a/packages/apptive_grid_form/test/common.dart b/packages/apptive_grid_form/test/common.dart index fa047aac..a6f8b833 100644 --- a/packages/apptive_grid_form/test/common.dart +++ b/packages/apptive_grid_form/test/common.dart @@ -14,7 +14,17 @@ import 'package:mocktail/mocktail.dart'; import 'package:permission_handler_platform_interface/permission_handler_platform_interface.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; -class MockApptiveGridClient extends Mock implements ApptiveGridClient {} +class MockApptiveGridClient extends Mock implements ApptiveGridClient { + /// Options the Widgets read through [ApptiveGrid.getOptions] + /// + /// A bare mock answers `null` here although the type says it cannot be, + /// which throws in every Widget that reads options — the cross reference + /// picker does, to find a [BarcodeScannerConfiguration]. A real field keeps + /// that from happening and lets a test hand configurations to the Widget + /// under test. + @override + ApptiveGridOptions options = const ApptiveGridOptions(); +} class MockHttpClient extends Mock implements http.Client {} @@ -93,7 +103,7 @@ class TestApp extends StatelessWidget { final client = MockApptiveGridClient(); when(() => client.sendPendingActions()) .thenAnswer((invocation) async => []); - when(() => client.options).thenReturn(options); + client.options = options; return client; } } diff --git a/packages/apptive_grid_form/test/geolocation_form_widget_test.dart b/packages/apptive_grid_form/test/geolocation_form_widget_test.dart index 1cd8f8c7..0f243575 100644 --- a/packages/apptive_grid_form/test/geolocation_form_widget_test.dart +++ b/packages/apptive_grid_form/test/geolocation_form_widget_test.dart @@ -1574,7 +1574,7 @@ void main() { throw 'MissingMockResponse for GET $requestUri'; }); - when(() => apptiveGridClient.options).thenReturn(options); + apptiveGridClient.options = options; when(() => apptiveGridClient.sendPendingActions()) .thenAnswer((_) async => []); when( From c36dbf56e50944d2f9664263f14845455b447669 Mon Sep 17 00:00:00 2001 From: Christian Denker Date: Sun, 13 Sep 2026 10:18:31 +0200 Subject: [PATCH 3/3] test: cover the scanner's remaining branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package enforces 100% coverage and CI found three lines short: the configuration's toString, and the branch that re-runs the query when a scan matches the text already in the filter. Both are reachable — the latter whenever someone types a code and then scans the same one, where without it the button would appear to do nothing — so they get tests rather than a coverage ignore. Co-Authored-By: Claude Opus 5 --- .../test/barcode_scanner_test.dart | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/apptive_grid_form/test/barcode_scanner_test.dart b/packages/apptive_grid_form/test/barcode_scanner_test.dart index 21d1f89b..6210b31c 100644 --- a/packages/apptive_grid_form/test/barcode_scanner_test.dart +++ b/packages/apptive_grid_form/test/barcode_scanner_test.dart @@ -27,6 +27,13 @@ FormFieldProperties _properties({required bool enableBarcodeScanner}) => ); void main() { + test('BarcodeScannerConfiguration describes itself', () { + expect( + _scanner('value').toString(), + equals('BarcodeScannerConfiguration(buttonTooltip: Scan)'), + ); + }); + setUpAll(() { registerFallbackValue( FormData(id: 'id', title: '', components: [], fields: [], links: {}), @@ -208,6 +215,23 @@ void main() { expect(_scanButton, findsOneWidget); }); + testWidgets('Scanning what is already typed searches again', + (tester) async { + // Setting the same text would not notify the filter listener, so the + // scan has to trigger the query itself — otherwise the button looks + // broken to someone who typed the code before scanning it + await pumpPicker(tester, configuration: _scanner('Second')); + + await tester.enterText(find.byType(TextField), 'Second'); + await tester.pumpAndSettle(); + expect(find.text('First'), findsNothing); + + await tester.tap(_scanButton); + await tester.pumpAndSettle(); + + expect(find.text('Second'), findsOneWidget); + }); + testWidgets('A scan selects the row it unambiguously points at', (tester) async { final scanned = [];