2
0
mirror of https://github.com/inventree/inventree-app.git synced 2025-04-27 04:56:48 +00:00

Scanner wedge mode (#437)

* Add code_scan_listener package

* Implement wedge controller widget

* Update barcode settings widget

- Allow user to choose which barcode scanner to use

* Fix typo

* Select barcode scanner widget based on user preference

* Fix rendering issues for wedge controller

* Update release notes

* Add unit test for wedge scanner widget

- Required some tweaks to other code

* Use better library

- https://github.com/fuadreza/flutter_barcode_listener
- Fork of https://github.com/shaxxx/flutter_barcode_listener
- Properly handles key "case" issues (shift, essentially)
- Verified that it works correctly for multiple character types

* Local copy of code, rather than relying on package which is not available on pub.dev

* Fix unit test
This commit is contained in:
Oliver 2023-10-25 22:40:49 +11:00 committed by GitHub
parent b6ab9d5da5
commit c641cea369
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 411 additions and 4 deletions

View File

@ -79,3 +79,4 @@ linter:
no_leading_underscores_for_local_identifiers: false no_leading_underscores_for_local_identifiers: false
use_super_parameters: false use_super_parameters: false

View File

@ -1,6 +1,7 @@
### 0.13.0 - October 2023 ### 0.13.0 - October 2023
--- ---
- Adds "wedge scanner" mode, allowing use with external barcode readers
- Add ability to scan in received items using supplier barcodes - Add ability to scan in received items using supplier barcodes
- Store API token, rather than username:password - Store API token, rather than username:password
- Ensure that user will lose access if token is revoked by server - Ensure that user will lose access if token is revoked by server

View File

@ -6,6 +6,11 @@ const Color COLOR_GRAY_LIGHT = Color.fromRGBO(150, 150, 150, 1);
// Return an "action" color based on the current theme // Return an "action" color based on the current theme
Color get COLOR_ACTION { Color get COLOR_ACTION {
// OneContext might not have context, e.g. in testing
if (!OneContext.hasContext) {
return Colors.lightBlue;
}
BuildContext? context = OneContext().context; BuildContext? context = OneContext().context;
if (context != null) { if (context != null) {

View File

@ -2,6 +2,7 @@ import "package:flutter/material.dart";
import "package:flutter_speed_dial/flutter_speed_dial.dart"; import "package:flutter_speed_dial/flutter_speed_dial.dart";
import "package:font_awesome_flutter/font_awesome_flutter.dart"; import "package:font_awesome_flutter/font_awesome_flutter.dart";
import "package:inventree/preferences.dart";
import "package:one_context/one_context.dart"; import "package:one_context/one_context.dart";
@ -11,6 +12,7 @@ import "package:inventree/helpers.dart";
import "package:inventree/l10.dart"; import "package:inventree/l10.dart";
import "package:inventree/barcode/camera_controller.dart"; import "package:inventree/barcode/camera_controller.dart";
import "package:inventree/barcode/wedge_controller.dart";
import "package:inventree/barcode/controller.dart"; import "package:inventree/barcode/controller.dart";
import "package:inventree/barcode/handler.dart"; import "package:inventree/barcode/handler.dart";
import "package:inventree/barcode/tones.dart"; import "package:inventree/barcode/tones.dart";
@ -44,6 +46,19 @@ Future<Object?> scanBarcode(BuildContext context, {BarcodeHandler? handler}) asy
InvenTreeBarcodeController controller = CameraBarcodeController(handler); InvenTreeBarcodeController controller = CameraBarcodeController(handler);
// Select barcode controller based on user preference
final int barcodeControllerType = await InvenTreeSettingsManager().getValue(INV_BARCODE_SCAN_TYPE, BARCODE_CONTROLLER_CAMERA) as int;
switch (barcodeControllerType) {
case BARCODE_CONTROLLER_WEDGE:
controller = WedgeBarcodeController(handler);
break;
case BARCODE_CONTROLLER_CAMERA:
default:
// Already set as default option
break;
}
return Navigator.of(context).push( return Navigator.of(context).push(
PageRouteBuilder( PageRouteBuilder(
pageBuilder: (context, _, __) => controller, pageBuilder: (context, _, __) => controller,

View File

@ -58,9 +58,9 @@ class InvenTreeBarcodeControllerState extends State<InvenTreeBarcodeController>
processingBarcode = true; processingBarcode = true;
}); });
BuildContext? context = OneContext().context; BuildContext? context = OneContext.hasContext ? OneContext().context : null;
showLoadingOverlay(context!); showLoadingOverlay(context);
await pauseScan(); await pauseScan();
await widget.handler.processBarcode(data); await widget.handler.processBarcode(data);

View File

@ -0,0 +1,175 @@
/*
* Custom keyboard listener which allows the app to act as a keyboard "wedge",
* and intercept barcodes from any compatible scanner.
*
* Note: This code was copied from https://github.com/fuadreza/flutter_barcode_listener/blob/master/lib/flutter_barcode_listener.dart
*
* If that code becomes available on pub.dev, we can remove this file and reference that library
*/
import "dart:async";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
typedef BarcodeScannedCallback = void Function(String barcode);
/// This widget will listen for raw PHYSICAL keyboard events
/// even when other controls have primary focus.
/// It will buffer all characters coming in specifed `bufferDuration` time frame
/// that end with line feed character and call callback function with result.
/// Keep in mind this widget will listen for events even when not visible.
/// Windows seems to be using the [RawKeyDownEvent] instead of the
/// [RawKeyUpEvent], this behaviour can be managed by setting [useKeyDownEvent].
class BarcodeKeyboardListener extends StatefulWidget {
/// This widget will listen for raw PHYSICAL keyboard events
/// even when other controls have primary focus.
/// It will buffer all characters coming in specifed `bufferDuration` time frame
/// that end with line feed character and call callback function with result.
/// Keep in mind this widget will listen for events even when not visible.
const BarcodeKeyboardListener(
{Key? key,
/// Child widget to be displayed.
required this.child,
/// Callback to be called when barcode is scanned.
required Function(String) onBarcodeScanned,
/// When experiencing issueswith empty barcodes on Windows,
/// set this value to true. Default value is `false`.
this.useKeyDownEvent = false,
/// Maximum time between two key events.
/// If time between two key events is longer than this value
/// previous keys will be ignored.
Duration bufferDuration = hundredMs})
: _onBarcodeScanned = onBarcodeScanned,
_bufferDuration = bufferDuration,
super(key: key);
final Widget child;
final BarcodeScannedCallback _onBarcodeScanned;
final Duration _bufferDuration;
final bool useKeyDownEvent;
@override
_BarcodeKeyboardListenerState createState() => _BarcodeKeyboardListenerState(
_onBarcodeScanned, _bufferDuration, useKeyDownEvent);
}
const Duration aSecond = Duration(seconds: 1);
const Duration hundredMs = Duration(milliseconds: 100);
const String lineFeed = "\n";
class _BarcodeKeyboardListenerState extends State<BarcodeKeyboardListener> {
_BarcodeKeyboardListenerState(this._onBarcodeScannedCallback,
this._bufferDuration, this._useKeyDownEvent) {
RawKeyboard.instance.addListener(_keyBoardCallback);
_keyboardSubscription =
_controller.stream.where((char) => char != null).listen(onKeyEvent);
}
List<String> _scannedChars = [];
DateTime? _lastScannedCharCodeTime;
late StreamSubscription<String?> _keyboardSubscription;
final BarcodeScannedCallback _onBarcodeScannedCallback;
final Duration _bufferDuration;
final _controller = StreamController<String?>();
final bool _useKeyDownEvent;
bool _isShiftPressed = false;
void onKeyEvent(String? char) {
//remove any pending characters older than bufferDuration value
checkPendingCharCodesToClear();
_lastScannedCharCodeTime = DateTime.now();
if (char == lineFeed) {
_onBarcodeScannedCallback.call(_scannedChars.join());
resetScannedCharCodes();
} else {
//add character to list of scanned characters;
_scannedChars.add(char!);
}
}
void checkPendingCharCodesToClear() {
if (_lastScannedCharCodeTime != null) {
if (_lastScannedCharCodeTime!
.isBefore(DateTime.now().subtract(_bufferDuration))) {
resetScannedCharCodes();
}
}
}
void resetScannedCharCodes() {
_lastScannedCharCodeTime = null;
_scannedChars = [];
}
void addScannedCharCode(String charCode) {
_scannedChars.add(charCode);
}
void _keyBoardCallback(RawKeyEvent keyEvent) {
if (keyEvent.logicalKey.keyId > 255 &&
keyEvent.data.logicalKey != LogicalKeyboardKey.enter &&
keyEvent.data.logicalKey != LogicalKeyboardKey.shiftLeft) return;
if ((!_useKeyDownEvent && keyEvent is RawKeyUpEvent) ||
(_useKeyDownEvent && keyEvent is RawKeyDownEvent)) {
if (keyEvent.data is RawKeyEventDataAndroid) {
if (keyEvent.data.logicalKey == LogicalKeyboardKey.shiftLeft) {
_isShiftPressed = true;
} else {
if (_isShiftPressed) {
_isShiftPressed = false;
_controller.sink.add(String.fromCharCode(
((keyEvent.data) as RawKeyEventDataAndroid).codePoint).toUpperCase());
} else {
_controller.sink.add(String.fromCharCode(
((keyEvent.data) as RawKeyEventDataAndroid).codePoint));
}
}
} else if (keyEvent.data is RawKeyEventDataFuchsia) {
_controller.sink.add(String.fromCharCode(
((keyEvent.data) as RawKeyEventDataFuchsia).codePoint));
} else if (keyEvent.data.logicalKey == LogicalKeyboardKey.enter) {
_controller.sink.add(lineFeed);
} else if (keyEvent.data is RawKeyEventDataWeb) {
_controller.sink.add(((keyEvent.data) as RawKeyEventDataWeb).keyLabel);
} else if (keyEvent.data is RawKeyEventDataLinux) {
_controller.sink
.add(((keyEvent.data) as RawKeyEventDataLinux).keyLabel);
} else if (keyEvent.data is RawKeyEventDataWindows) {
_controller.sink.add(String.fromCharCode(
((keyEvent.data) as RawKeyEventDataWindows).keyCode));
} else if (keyEvent.data is RawKeyEventDataMacOs) {
_controller.sink
.add(((keyEvent.data) as RawKeyEventDataMacOs).characters);
} else if (keyEvent.data is RawKeyEventDataIos) {
_controller.sink
.add(((keyEvent.data) as RawKeyEventDataIos).characters);
} else {
_controller.sink.add(keyEvent.character);
}
}
}
@override
Widget build(BuildContext context) {
return widget.child;
}
@override
void dispose() {
_keyboardSubscription.cancel();
_controller.close();
RawKeyboard.instance.removeListener(_keyBoardCallback);
super.dispose();
}
}

View File

@ -0,0 +1,102 @@
import "package:flutter/material.dart";
import "package:font_awesome_flutter/font_awesome_flutter.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/barcode/controller.dart";
import "package:inventree/barcode/handler.dart";
import "package:inventree/barcode/flutter_barcode_listener.dart";
import "package:inventree/l10.dart";
import "package:inventree/helpers.dart";
/*
* Barcode controller which acts as a keyboard wedge,
* intercepting barcode data which is entered as rapid keyboard presses
*/
class WedgeBarcodeController extends InvenTreeBarcodeController {
const WedgeBarcodeController(BarcodeHandler handler, {Key? key}) : super(handler, key: key);
@override
State<StatefulWidget> createState() => _WedgeBarcodeControllerState();
}
class _WedgeBarcodeControllerState extends InvenTreeBarcodeControllerState {
_WedgeBarcodeControllerState() : super();
bool canScan = true;
bool get scanning => mounted && canScan;
@override
Future<void> pauseScan() async {
if (mounted) {
setState(() {
canScan = false;
});
}
}
@override
Future<void> resumeScan() async {
if (mounted) {
setState(() {
canScan = true;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(L10().scanBarcode),
),
backgroundColor: Colors.black.withOpacity(0.9),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Spacer(flex: 5),
FaIcon(FontAwesomeIcons.barcode, size: 64),
Spacer(flex: 5),
BarcodeKeyboardListener(
useKeyDownEvent: true,
child: SizedBox(
child: CircularProgressIndicator(
color: scanning ? COLOR_ACTION : COLOR_PROGRESS
),
width: 64,
height: 64,
),
onBarcodeScanned: (String barcode) {
debug("scanned: ${barcode}");
if (scanning) {
// Process the barcode data
handleBarcodeData(barcode);
}
},
),
Spacer(flex: 5),
Padding(
child: Text(
widget.handler.getOverlayText(context),
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white)
),
padding: EdgeInsets.all(20),
)
],
)
)
);
}
}

View File

@ -118,6 +118,12 @@
"barcodeScanAssign": "Scan to assign barcode", "barcodeScanAssign": "Scan to assign barcode",
"@barcodeScanAssign": {}, "@barcodeScanAssign": {},
"barcodeScanController": "Scanner Input",
"@barcodeScanController": {},
"barcodeScanControllerDetail": "Select barcode scanner input source",
"@barcodeScanControllerDetail": {},
"barcodeScanDelay": "Barcode Scan Delay", "barcodeScanDelay": "Barcode Scan Delay",
"@barcodeScanDelay": {}, "@barcodeScanDelay": {},
@ -169,6 +175,12 @@
"building": "Building", "building": "Building",
"@building": {}, "@building": {},
"cameraInternal": "Internal Camera",
"@cameraInternal": {},
"cameraInternalDetail": "Use internal camera to read barcodes",
"@cameraInternalDetail": {},
"cancel": "Cancel", "cancel": "Cancel",
"@cancel": { "@cancel": {
"description": "Cancel" "description": "Cancel"
@ -1003,6 +1015,12 @@
"scanIntoLocationDetail": "Scan this item into location", "scanIntoLocationDetail": "Scan this item into location",
"@scanIntoLocationDetail": {}, "@scanIntoLocationDetail": {},
"scannerExternal": "External Scanner",
"@scannerExternal": {},
"scannerExternalDetail": "Use external scanner to read barcodes (wedge mode)",
"@scannerExternalDetail": {},
"scanReceivedParts": "Scan Received Parts", "scanReceivedParts": "Scan Received Parts",
"@scanReceivedParts": {}, "@scanReceivedParts": {},

View File

@ -40,6 +40,11 @@ const String INV_STRICT_HTTPS = "strictHttps";
// Barcode settings // Barcode settings
const String INV_BARCODE_SCAN_DELAY = "barcodeScanDelay"; const String INV_BARCODE_SCAN_DELAY = "barcodeScanDelay";
const String INV_BARCODE_SCAN_TYPE = "barcodeScanType";
// Barcode scanner types
const int BARCODE_CONTROLLER_CAMERA = 0;
const int BARCODE_CONTROLLER_WEDGE = 1;
/* /*
* Class for storing InvenTree preferences in a NoSql DB * Class for storing InvenTree preferences in a NoSql DB

View File

@ -1,7 +1,9 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:font_awesome_flutter/font_awesome_flutter.dart"; import "package:font_awesome_flutter/font_awesome_flutter.dart";
import "package:inventree/l10.dart"; import "package:inventree/l10.dart";
import "package:inventree/preferences.dart"; import "package:inventree/preferences.dart";
import "package:inventree/widget/dialogs.dart";
class InvenTreeBarcodeSettingsWidget extends StatefulWidget { class InvenTreeBarcodeSettingsWidget extends StatefulWidget {
@ -15,6 +17,7 @@ class _InvenTreeBarcodeSettingsState extends State<InvenTreeBarcodeSettingsWidge
_InvenTreeBarcodeSettingsState(); _InvenTreeBarcodeSettingsState();
int barcodeScanDelay = 500; int barcodeScanDelay = 500;
int barcodeScanType = BARCODE_CONTROLLER_CAMERA;
final TextEditingController _barcodeScanDelayController = TextEditingController(); final TextEditingController _barcodeScanDelayController = TextEditingController();
@ -26,6 +29,7 @@ class _InvenTreeBarcodeSettingsState extends State<InvenTreeBarcodeSettingsWidge
Future<void> loadSettings() async { Future<void> loadSettings() async {
barcodeScanDelay = await InvenTreeSettingsManager().getValue(INV_BARCODE_SCAN_DELAY, 500) as int; barcodeScanDelay = await InvenTreeSettingsManager().getValue(INV_BARCODE_SCAN_DELAY, 500) as int;
barcodeScanType = await InvenTreeSettingsManager().getValue(INV_BARCODE_SCAN_TYPE, BARCODE_CONTROLLER_CAMERA) as int;
if (mounted) { if (mounted) {
setState(() { setState(() {
@ -89,11 +93,55 @@ class _InvenTreeBarcodeSettingsState extends State<InvenTreeBarcodeSettingsWidge
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Construct an icon for the barcode scanner input
Widget? barcodeInputIcon;
switch (barcodeScanType) {
case BARCODE_CONTROLLER_WEDGE:
barcodeInputIcon = Icon(Icons.barcode_reader);
break;
case BARCODE_CONTROLLER_CAMERA:
default:
barcodeInputIcon = FaIcon(FontAwesomeIcons.camera);
break;
}
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(L10().barcodes)), appBar: AppBar(title: Text(L10().barcodes)),
body: Container( body: Container(
child: ListView( child: ListView(
children: [ children: [
ListTile(
title: Text(L10().barcodeScanController),
subtitle: Text(L10().barcodeScanControllerDetail),
leading: Icon(Icons.qr_code_scanner),
trailing: barcodeInputIcon,
onTap: () async {
choiceDialog(
L10().barcodeScanController,
[
ListTile(
title: Text(L10().cameraInternal),
subtitle: Text(L10().cameraInternalDetail),
leading: FaIcon(FontAwesomeIcons.camera),
),
ListTile(
title: Text(L10().scannerExternal),
subtitle: Text(L10().scannerExternalDetail),
leading: Icon(Icons.barcode_reader),
)
],
onSelected: (idx) async {
barcodeScanType = idx as int;
InvenTreeSettingsManager().setValue(INV_BARCODE_SCAN_TYPE, barcodeScanType);
if (mounted) {
setState(() {});
}
}
);
}
),
ListTile( ListTile(
title: Text(L10().barcodeScanDelay), title: Text(L10().barcodeScanDelay),
subtitle: Text(L10().barcodeScanDelayDetail), subtitle: Text(L10().barcodeScanDelayDetail),
@ -104,7 +152,7 @@ class _InvenTreeBarcodeSettingsState extends State<InvenTreeBarcodeSettingsWidge
_editBarcodeScanDelay(context); _editBarcodeScanDelay(context);
}, },
), ),
) ),
], ],
) )
) )

View File

@ -14,7 +14,12 @@ Widget progressIndicator() {
} }
void showLoadingOverlay(BuildContext context) { void showLoadingOverlay(BuildContext? context) {
if (context == null) {
return;
}
Loader.show( Loader.show(
context, context,
themeData: Theme.of(context).copyWith(colorScheme: ColorScheme.fromSwatch()) themeData: Theme.of(context).copyWith(colorScheme: ColorScheme.fromSwatch())

View File

@ -0,0 +1,32 @@
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:flutter_test/flutter_test.dart";
import "package:inventree/barcode/barcode.dart";
import "package:inventree/barcode/wedge_controller.dart";
import "package:inventree/helpers.dart";
void main() {
testWidgets("Wedge Scanner Test", (tester) async {
await tester.pumpWidget(
MaterialApp(
home: WedgeBarcodeController(BarcodeScanHandler())
)
);
// Generate some keyboard data
await simulateKeyDownEvent(LogicalKeyboardKey.keyA);
await simulateKeyDownEvent(LogicalKeyboardKey.keyB);
await simulateKeyDownEvent(LogicalKeyboardKey.keyC);
await simulateKeyDownEvent(LogicalKeyboardKey.enter);
// Check debug output
debugContains("scanned: abc");
debugContains("No match for barcode");
debugContains("Server Error");
});
}