Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/apptive_grid_form/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
33 changes: 33 additions & 0 deletions packages/apptive_grid_form/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>(
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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>(
/// 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<String?> 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)';
}
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export 'barcode_scanner_configuration.dart';
export 'geolocation_form_widget_configuration.dart';
export 'label_configuration.dart';
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> onScanned,
}) {
if (!fieldAsksForScanner) {
return null;
}
final configuration = ApptiveGrid.getOptions(context)
.formWidgetConfigurations
.whereType<BarcodeScannerConfiguration>()
.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<String> 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,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -61,6 +62,13 @@ class CrossReferenceDropdownButtonFormFieldState<T extends DataEntity>

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<String?>(null);

late final Uri _gridUri;

final _overlayKey = GlobalKey();
Expand Down Expand Up @@ -193,6 +201,15 @@ class CrossReferenceDropdownButtonFormFieldState<T extends DataEntity>
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,
),
Expand All @@ -216,6 +233,7 @@ class CrossReferenceDropdownButtonFormFieldState<T extends DataEntity>
enabled: false,
child: _CrossReferenceSelectionGrid(
controller: _filterController,
scannedValue: _scannedValue,
scrollControllerGroup: _scrollControllerGroup,
grid: _grid!,
selectedNotifier: widget.selectedNotifier,
Expand Down Expand Up @@ -268,13 +286,15 @@ class CrossReferenceDropdownButtonFormFieldState<T extends DataEntity>
class _CrossReferenceSelectionGrid extends StatefulWidget {
const _CrossReferenceSelectionGrid({
required this.controller,
required this.scannedValue,
required this.scrollControllerGroup,
required this.grid,
required this.selectedNotifier,
required this.onSelected,
});

final TextEditingController controller;
final ValueNotifier<String?> scannedValue;
final LinkedScrollControllerGroup scrollControllerGroup;
final Grid grid;
final SelectedRowsNotifier selectedNotifier;
Expand All @@ -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<GridRow>? 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
Expand All @@ -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();
}
Expand Down Expand Up @@ -408,12 +461,14 @@ class _CrossReferenceSelectionGridState
setState(() {
_rows = rows;
});
_selectScannedMatch(rows);
}
} else {
if (mounted) {
setState(() {
_rows = [];
});
_selectScannedMatch(const []);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ Widget fromModel(
FormFieldProperties? properties,
}) =>
switch (component.field.type) {
DataType.text ||
DataType.richText =>
TextFormWidget(component: component.cast<StringDataEntity>()),
DataType.text || DataType.richText => TextFormWidget(
component: component.cast<StringDataEntity>(),
fieldProperties: properties,
),
DataType.dateTime => DateTimeFormWidget(
component: component.cast<DateTimeDataEntity>(),
),
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -9,11 +10,15 @@ class TextFormWidget extends StatefulWidget {
const TextFormWidget({
super.key,
required this.component,
this.fieldProperties,
});

/// Component this Widget should reflect
final FormComponent<StringDataEntity> component;

/// Properties of the component's field, used for the barcode scanner
final FormFieldProperties? fieldProperties;

@override
State<TextFormWidget> createState() => _TextFormWidgetState();
}
Expand Down Expand Up @@ -56,7 +61,15 @@ class _TextFormWidgetState extends State<TextFormWidget>
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,
);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/apptive_grid_form/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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

Expand Down
6 changes: 3 additions & 3 deletions packages/apptive_grid_form/test/apptive_grid_form_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading