diff --git a/packages/apptive_grid_form/CHANGELOG.md b/packages/apptive_grid_form/CHANGELOG.md index a68007b4..9ff236ed 100644 --- a/packages/apptive_grid_form/CHANGELOG.md +++ b/packages/apptive_grid_form/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.5.0 + + - **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 - **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..7a48fdae 100644 --- a/packages/apptive_grid_form/README.md +++ b/packages/apptive_grid_form/README.md @@ -161,6 +161,39 @@ 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. Without the configuration no button is shown anywhere. + +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. + ### 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..04e07733 --- /dev/null +++ b/packages/apptive_grid_form/lib/src/configurations/barcode_scanner_configuration.dart @@ -0,0 +1,57 @@ +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: ..., +/// ) +/// ``` +/// +/// 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({ + 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..28cdff66 --- /dev/null +++ b/packages/apptive_grid_form/lib/src/widgets/form_widget/barcode_scan_button.dart @@ -0,0 +1,69 @@ +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 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 bool fieldAsksForScanner, + required bool enabled, + required ValueChanged onScanned, +}) { + if (!fieldAsksForScanner) { + 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..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 @@ -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'; @@ -61,6 +62,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 +201,15 @@ class CrossReferenceDropdownButtonFormFieldState Icons.search, color: Theme.of(context).textTheme.displayLarge?.color, ), + suffixIcon: barcodeScanButton( + context, + // 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, + ), hintText: localization.crossRefSearch, isDense: true, ), @@ -216,6 +233,7 @@ class CrossReferenceDropdownButtonFormFieldState enabled: false, child: _CrossReferenceSelectionGrid( controller: _filterController, + scannedValue: _scannedValue, scrollControllerGroup: _scrollControllerGroup, grid: _grid!, selectedNotifier: widget.selectedNotifier, @@ -268,6 +286,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 +294,7 @@ class _CrossReferenceSelectionGrid extends StatefulWidget { }); final TextEditingController controller; + final ValueNotifier scannedValue; final LinkedScrollControllerGroup scrollControllerGroup; final Grid grid; final SelectedRowsNotifier selectedNotifier; @@ -296,6 +316,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 +362,7 @@ class _CrossReferenceSelectionGridState @override void dispose() { widget.controller.removeListener(_loadRows); + widget.scannedValue.removeListener(_onScanned); for (final controller in _scrollControllers.values) { controller.dispose(); } @@ -408,12 +461,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/form_widget.dart b/packages/apptive_grid_form/lib/src/widgets/form_widget/form_widget.dart index 4dd93360..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 @@ -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(), ), 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..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 @@ -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,15 @@ 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, + fieldAsksForScanner: + widget.fieldProperties?.enableBarcodeScanner == true, + 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/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 new file mode 100644 index 00000000..6210b31c --- /dev/null +++ b/packages/apptive_grid_form/test/barcode_scanner_test.dart @@ -0,0 +1,254 @@ +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() { + test('BarcodeScannerConfiguration describes itself', () { + expect( + _scanner('value').toString(), + equals('BarcodeScannerConfiguration(buttonTooltip: Scan)'), + ); + }); + + 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, { + BarcodeScannerConfiguration? configuration, + }) async { + // ApptiveGrid.getOptions reads them off the client, so the mock has to + // carry them rather than TestApp + (client as MockApptiveGridClient).options = ApptiveGridOptions( + formWidgetConfigurations: [ + if (configuration != null) configuration, + ], + ); + await tester.pumpWidget( + TestApp( + client: client, + child: CrossReferenceFormWidget( + component: FormComponent( + property: 'Property', + data: CrossReferenceDataEntity(gridUri: gridUri), + field: crossRefField, + ), + ), + ), + ); + 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); + 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('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 = []; + await pumpPicker( + tester, + 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); + }); + }); +} 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(