From c641cea3693023e2d3a5d96ee202afaea331fdcd Mon Sep 17 00:00:00 2001 From: Oliver Date: Wed, 25 Oct 2023 22:40:49 +1100 Subject: [PATCH] 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 --- analysis_options.yaml | 1 + assets/release_notes.md | 1 + lib/app_colors.dart | 5 + lib/barcode/barcode.dart | 15 ++ lib/barcode/controller.dart | 4 +- lib/barcode/flutter_barcode_listener.dart | 175 ++++++++++++++++++++++ lib/barcode/wedge_controller.dart | 102 +++++++++++++ lib/l10n/app_en.arb | 18 +++ lib/preferences.dart | 5 + lib/settings/barcode_settings.dart | 50 ++++++- lib/widget/progress.dart | 7 +- test/wedge_scanner_test.dart | 32 ++++ 12 files changed, 411 insertions(+), 4 deletions(-) create mode 100644 lib/barcode/flutter_barcode_listener.dart create mode 100644 lib/barcode/wedge_controller.dart create mode 100644 test/wedge_scanner_test.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index 217d3d29..c470baee 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -79,3 +79,4 @@ linter: no_leading_underscores_for_local_identifiers: false use_super_parameters: false + diff --git a/assets/release_notes.md b/assets/release_notes.md index 7604eec8..0a25673e 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -1,6 +1,7 @@ ### 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 - Store API token, rather than username:password - Ensure that user will lose access if token is revoked by server diff --git a/lib/app_colors.dart b/lib/app_colors.dart index feba6c09..5fb4bb13 100644 --- a/lib/app_colors.dart +++ b/lib/app_colors.dart @@ -6,6 +6,11 @@ const Color COLOR_GRAY_LIGHT = Color.fromRGBO(150, 150, 150, 1); // Return an "action" color based on the current theme Color get COLOR_ACTION { + // OneContext might not have context, e.g. in testing + if (!OneContext.hasContext) { + return Colors.lightBlue; + } + BuildContext? context = OneContext().context; if (context != null) { diff --git a/lib/barcode/barcode.dart b/lib/barcode/barcode.dart index 6a2a069c..df110e6f 100644 --- a/lib/barcode/barcode.dart +++ b/lib/barcode/barcode.dart @@ -2,6 +2,7 @@ import "package:flutter/material.dart"; import "package:flutter_speed_dial/flutter_speed_dial.dart"; import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:inventree/preferences.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/barcode/camera_controller.dart"; +import "package:inventree/barcode/wedge_controller.dart"; import "package:inventree/barcode/controller.dart"; import "package:inventree/barcode/handler.dart"; import "package:inventree/barcode/tones.dart"; @@ -44,6 +46,19 @@ Future scanBarcode(BuildContext context, {BarcodeHandler? handler}) asy 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( PageRouteBuilder( pageBuilder: (context, _, __) => controller, diff --git a/lib/barcode/controller.dart b/lib/barcode/controller.dart index ca8e3966..ad17aa65 100644 --- a/lib/barcode/controller.dart +++ b/lib/barcode/controller.dart @@ -58,9 +58,9 @@ class InvenTreeBarcodeControllerState extends State processingBarcode = true; }); - BuildContext? context = OneContext().context; + BuildContext? context = OneContext.hasContext ? OneContext().context : null; - showLoadingOverlay(context!); + showLoadingOverlay(context); await pauseScan(); await widget.handler.processBarcode(data); diff --git a/lib/barcode/flutter_barcode_listener.dart b/lib/barcode/flutter_barcode_listener.dart new file mode 100644 index 00000000..c2b28450 --- /dev/null +++ b/lib/barcode/flutter_barcode_listener.dart @@ -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 { + + _BarcodeKeyboardListenerState(this._onBarcodeScannedCallback, + this._bufferDuration, this._useKeyDownEvent) { + RawKeyboard.instance.addListener(_keyBoardCallback); + _keyboardSubscription = + _controller.stream.where((char) => char != null).listen(onKeyEvent); + } + + List _scannedChars = []; + DateTime? _lastScannedCharCodeTime; + late StreamSubscription _keyboardSubscription; + + final BarcodeScannedCallback _onBarcodeScannedCallback; + final Duration _bufferDuration; + + final _controller = StreamController(); + + 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(); + } +} \ No newline at end of file diff --git a/lib/barcode/wedge_controller.dart b/lib/barcode/wedge_controller.dart new file mode 100644 index 00000000..98b9f0ce --- /dev/null +++ b/lib/barcode/wedge_controller.dart @@ -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 createState() => _WedgeBarcodeControllerState(); + +} + + +class _WedgeBarcodeControllerState extends InvenTreeBarcodeControllerState { + + _WedgeBarcodeControllerState() : super(); + + bool canScan = true; + + bool get scanning => mounted && canScan; + + @override + Future pauseScan() async { + + if (mounted) { + setState(() { + canScan = false; + }); + } + } + + @override + Future 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), + ) + ], + ) + ) + ); + } + +} \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4d858d0d..e1c1f2ea 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -118,6 +118,12 @@ "barcodeScanAssign": "Scan to assign barcode", "@barcodeScanAssign": {}, + "barcodeScanController": "Scanner Input", + "@barcodeScanController": {}, + + "barcodeScanControllerDetail": "Select barcode scanner input source", + "@barcodeScanControllerDetail": {}, + "barcodeScanDelay": "Barcode Scan Delay", "@barcodeScanDelay": {}, @@ -169,6 +175,12 @@ "building": "Building", "@building": {}, + "cameraInternal": "Internal Camera", + "@cameraInternal": {}, + + "cameraInternalDetail": "Use internal camera to read barcodes", + "@cameraInternalDetail": {}, + "cancel": "Cancel", "@cancel": { "description": "Cancel" @@ -1003,6 +1015,12 @@ "scanIntoLocationDetail": "Scan this item into location", "@scanIntoLocationDetail": {}, + "scannerExternal": "External Scanner", + "@scannerExternal": {}, + + "scannerExternalDetail": "Use external scanner to read barcodes (wedge mode)", + "@scannerExternalDetail": {}, + "scanReceivedParts": "Scan Received Parts", "@scanReceivedParts": {}, diff --git a/lib/preferences.dart b/lib/preferences.dart index 51801527..30105978 100644 --- a/lib/preferences.dart +++ b/lib/preferences.dart @@ -40,6 +40,11 @@ const String INV_STRICT_HTTPS = "strictHttps"; // Barcode settings 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 diff --git a/lib/settings/barcode_settings.dart b/lib/settings/barcode_settings.dart index 1624a10f..44dfb7f3 100644 --- a/lib/settings/barcode_settings.dart +++ b/lib/settings/barcode_settings.dart @@ -1,7 +1,9 @@ import "package:flutter/material.dart"; import "package:font_awesome_flutter/font_awesome_flutter.dart"; + import "package:inventree/l10.dart"; import "package:inventree/preferences.dart"; +import "package:inventree/widget/dialogs.dart"; class InvenTreeBarcodeSettingsWidget extends StatefulWidget { @@ -15,6 +17,7 @@ class _InvenTreeBarcodeSettingsState extends State loadSettings() async { 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) { setState(() { @@ -89,11 +93,55 @@ class _InvenTreeBarcodeSettingsState extends State