diff --git a/assets/release_notes.md b/assets/release_notes.md index 16a736b2..d41beb30 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -3,6 +3,7 @@ - Support "active" field for Company model - Support "active" field for SupplierPart model +- Adjustments to barcode scanning workflow - Updated translations ### 0.14.2 - February 2024 diff --git a/lib/api_form.dart b/lib/api_form.dart index 31e5d3ee..f2578dff 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -10,7 +10,6 @@ import "package:flutter/material.dart"; import "package:inventree/api.dart"; import "package:inventree/app_colors.dart"; import "package:inventree/barcode/barcode.dart"; -import "package:inventree/barcode/tones.dart"; import "package:inventree/helpers.dart"; import "package:inventree/inventree/sales_order.dart"; import "package:inventree/l10.dart"; @@ -342,12 +341,7 @@ class APIFormField { controller.text = hash; data["value"] = hash; - barcodeSuccessTone(); - - showSnackIcon( - L10().barcodeAssigned, - success: true - ); + barcodeSuccess(L10().barcodeAssigned); }); scanBarcode(context, handler: handler); diff --git a/lib/barcode/barcode.dart b/lib/barcode/barcode.dart index 91e600c4..fead1b3d 100644 --- a/lib/barcode/barcode.dart +++ b/lib/barcode/barcode.dart @@ -9,7 +9,6 @@ import "package:one_context/one_context.dart"; import "package:inventree/api.dart"; -import "package:inventree/helpers.dart"; import "package:inventree/l10.dart"; import "package:inventree/barcode/camera_controller.dart"; @@ -33,6 +32,35 @@ import "package:inventree/widget/stock/stock_detail.dart"; import "package:inventree/widget/company/supplier_part_detail.dart"; +// Signal a barcode scan success to the user +Future barcodeSuccess(String msg) async { + + barcodeSuccessTone(); + showSnackIcon(msg, success: true); +} + +// Signal a barcode scan failure to the user +Future barcodeFailure(String msg, dynamic extra) async { + barcodeFailureTone(); + showSnackIcon( + msg, + success: false, + onAction: () { + OneContext().showDialog( + builder: (BuildContext context) => SimpleDialog( + title: Text(L10().barcodeError), + children: [ + ListTile( + title: Text(L10().responseData), + subtitle: Text(extra.toString()) + ) + ] + ) + ); + } + ); +} + /* * Launch a barcode scanner with a particular context and handler. * @@ -266,234 +294,6 @@ class BarcodeScanHandler extends BarcodeHandler { } -/* - * Generic class for scanning a StockLocation. - * - * - Validates that the scanned barcode matches a valid StockLocation - * - Runs a "callback" function if a valid StockLocation is found - */ -class BarcodeScanStockLocationHandler extends BarcodeHandler { - - @override - String getOverlayText(BuildContext context) => L10().barcodeScanLocation; - - @override - Future onBarcodeMatched(Map data) async { - - // We expect that the barcode points to a 'stocklocation' - if (data.containsKey("stocklocation")) { - int _loc = (data["stocklocation"]["pk"] ?? -1) as int; - - // A valid stock location! - if (_loc > 0) { - - debug("Scanned stock location ${_loc}"); - - final bool result = await onLocationScanned(_loc); - - if (result && OneContext.hasContext) { - OneContext().pop(); - return; - } - } - } - - // If we get to this point, something went wrong during the scan process - barcodeFailureTone(); - - showSnackIcon( - L10().invalidStockLocation, - success: false, - ); - } - - // Callback function which runs when a valid StockLocation is scanned - // If this function returns 'true' the barcode scanning dialog will be closed - Future onLocationScanned(int locationId) async { - // Re-implement this for particular subclass - return false; - } - -} - - -/* - * Generic class for scanning a StockItem - * - * - Validates that the scanned barcode matches a valid StockItem - * - Runs a "callback" function if a valid StockItem is found - */ -class BarcodeScanStockItemHandler extends BarcodeHandler { - - @override - String getOverlayText(BuildContext context) => L10().barcodeScanItem; - - @override - Future onBarcodeMatched(Map data) async { - // We expect that the barcode points to a 'stockitem' - if (data.containsKey("stockitem")) { - int _item = (data["stockitem"]["pk"] ?? -1) as int; - - // A valid stock location! - if (_item > 0) { - - barcodeSuccessTone(); - - bool result = await onItemScanned(_item); - - if (result && OneContext.hasContext) { - OneContext().pop(); - return; - } - } - } - - // If we get to this point, something went wrong during the scan process - barcodeFailureTone(); - - showSnackIcon( - L10().invalidStockItem, - success: false, - ); - } - - // Callback function which runs when a valid StockItem is scanned - Future onItemScanned(int itemId) async { - // Re-implement this for particular subclass - return false; - } -} - - -/* - * Barcode handler for scanning a provided StockItem into a scanned StockLocation. - * - * - The class is initialized by passing a valid StockItem object - * - Expects to scan barcode for a StockLocation - * - The StockItem is transferred into the scanned location - */ -class StockItemScanIntoLocationHandler extends BarcodeScanStockLocationHandler { - - StockItemScanIntoLocationHandler(this.item); - - final InvenTreeStockItem item; - - @override - Future onLocationScanned(int locationId) async { - - final result = await item.transferStock(locationId); - - if (result) { - barcodeSuccessTone(); - showSnackIcon(L10().barcodeScanIntoLocationSuccess, success: true); - } else { - barcodeFailureTone(); - showSnackIcon(L10().barcodeScanIntoLocationFailure, success: false); - } - - return result; - } -} - - -/* - * Barcode handler for scanning stock item(s) into the specified StockLocation. - * - * - The class is initialized by passing a valid StockLocation object - * - Expects to scan a barcode for a StockItem - * - The scanned StockItem is transferred into the provided StockLocation - */ -class StockLocationScanInItemsHandler extends BarcodeScanStockItemHandler { - - StockLocationScanInItemsHandler(this.location); - - final InvenTreeStockLocation location; - - @override - String getOverlayText(BuildContext context) => L10().barcodeScanItem; - - @override - Future onItemScanned(int itemId) async { - - final InvenTreeStockItem? item = await InvenTreeStockItem().get(itemId) as InvenTreeStockItem?; - - bool result = false; - - if (item != null) { - - // Item is already *in* the specified location - if (item.locationId == location.pk) { - barcodeFailureTone(); - showSnackIcon(L10().itemInLocation, success: true); - return false; - } else { - result = await item.transferStock(location.pk); - } - } - - showSnackIcon( - result ? L10().barcodeScanIntoLocationSuccess : L10().barcodeScanIntoLocationFailure, - success: result - ); - - // We always return false here, to ensure the barcode scan dialog remains open - return false; - } -} - - -/* - * Barcode handler class for scanning a StockLocation into another StockLocation - * - * - The class is initialized by passing a valid StockLocation object - * - Expects to scan barcode for another *parent* StockLocation - * - The scanned StockLocation is set as the "parent" of the provided StockLocation - */ -class ScanParentLocationHandler extends BarcodeScanStockLocationHandler { - - ScanParentLocationHandler(this.location); - - final InvenTreeStockLocation location; - - @override - Future onLocationScanned(int locationId) async { - - final response = await location.update( - values: { - "parent": locationId.toString(), - }, - expectedStatusCode: null, - ); - - switch (response.statusCode) { - case 200: - case 201: - barcodeSuccessTone(); - showSnackIcon(L10().barcodeScanIntoLocationSuccess, success: true); - return true; - case 400: // Invalid parent location chosen - barcodeFailureTone(); - showSnackIcon(L10().invalidStockLocation, success: false); - return false; - default: - barcodeFailureTone(); - showSnackIcon( - L10().barcodeScanIntoLocationFailure, - success: false, - actionText: L10().details, - onAction: () { - showErrorDialog( - L10().barcodeError, - response: response, - ); - } - ); - return false; - } - } -} - - /* * Barcode handler for finding a "unique" barcode (one that does not match an item in the database) */ diff --git a/lib/barcode/purchase_order.dart b/lib/barcode/purchase_order.dart index 8fe67b89..8c91fed8 100644 --- a/lib/barcode/purchase_order.dart +++ b/lib/barcode/purchase_order.dart @@ -6,6 +6,7 @@ import "package:one_context/one_context.dart"; import "package:inventree/l10.dart"; import "package:inventree/api_form.dart"; +import "package:inventree/barcode/barcode.dart"; import "package:inventree/barcode/handler.dart"; import "package:inventree/barcode/tones.dart"; @@ -51,8 +52,7 @@ class POReceiveBarcodeHandler extends BarcodeHandler { return onBarcodeUnknown(data); } - barcodeSuccessTone(); - showSnackIcon(L10().receivedItem, success: true); + barcodeSuccess(L10().receivedItem); } @override diff --git a/lib/barcode/sales_order.dart b/lib/barcode/sales_order.dart index 69583d91..bc427467 100644 --- a/lib/barcode/sales_order.dart +++ b/lib/barcode/sales_order.dart @@ -8,6 +8,7 @@ import "package:one_context/one_context.dart"; import "package:inventree/l10.dart"; +import "package:inventree/barcode/barcode.dart"; import "package:inventree/barcode/handler.dart"; import "package:inventree/barcode/tones.dart"; @@ -115,8 +116,7 @@ class SOAllocateStockHandler extends BarcodeHandler { return onBarcodeUnknown(data); } - barcodeSuccessTone(); - showSnackIcon(L10().allocated, success: true); + barcodeSuccess(L10().allocated); } @override diff --git a/lib/barcode/stock.dart b/lib/barcode/stock.dart new file mode 100644 index 00000000..8de6ed5e --- /dev/null +++ b/lib/barcode/stock.dart @@ -0,0 +1,292 @@ +import "package:flutter/cupertino.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:inventree/api_form.dart"; +import "package:inventree/preferences.dart"; +import "package:one_context/one_context.dart"; + +import "package:inventree/helpers.dart"; +import "package:inventree/l10.dart"; + +import "package:inventree/barcode/barcode.dart"; +import "package:inventree/barcode/handler.dart"; +import "package:inventree/barcode/tones.dart"; + +import "package:inventree/inventree/stock.dart"; + +import "package:inventree/widget/dialogs.dart"; +import "package:inventree/widget/snacks.dart"; + + +/* + * Generic class for scanning a StockLocation. + * + * - Validates that the scanned barcode matches a valid StockLocation + * - Runs a "callback" function if a valid StockLocation is found + */ +class BarcodeScanStockLocationHandler extends BarcodeHandler { + + @override + String getOverlayText(BuildContext context) => L10().barcodeScanLocation; + + @override + Future onBarcodeMatched(Map data) async { + + // We expect that the barcode points to a 'stocklocation' + if (data.containsKey("stocklocation")) { + int _loc = (data["stocklocation"]["pk"] ?? -1) as int; + + // A valid stock location! + if (_loc > 0) { + + debug("Scanned stock location ${_loc}"); + + final bool result = await onLocationScanned(_loc); + + if (result && OneContext.hasContext) { + OneContext().pop(); + return; + } + } + } + + // If we get to this point, something went wrong during the scan process + barcodeFailureTone(); + + showSnackIcon( + L10().invalidStockLocation, + success: false, + ); + } + + // Callback function which runs when a valid StockLocation is scanned + // If this function returns 'true' the barcode scanning dialog will be closed + Future onLocationScanned(int locationId) async { + // Re-implement this for particular subclass + return false; + } + +} + + +/* + * Generic class for scanning a StockItem + * + * - Validates that the scanned barcode matches a valid StockItem + * - Runs a "callback" function if a valid StockItem is found + */ +class BarcodeScanStockItemHandler extends BarcodeHandler { + + @override + String getOverlayText(BuildContext context) => L10().barcodeScanItem; + + @override + Future onBarcodeMatched(Map data) async { + // We expect that the barcode points to a 'stockitem' + if (data.containsKey("stockitem")) { + int _item = (data["stockitem"]["pk"] ?? -1) as int; + + // A valid stock location! + if (_item > 0) { + + barcodeSuccessTone(); + + bool result = await onItemScanned(_item); + + if (result && OneContext.hasContext) { + OneContext().pop(); + return; + } + } + } + + // If we get to this point, something went wrong during the scan process + barcodeFailureTone(); + + showSnackIcon( + L10().invalidStockItem, + success: false, + ); + } + + // Callback function which runs when a valid StockItem is scanned + Future onItemScanned(int itemId) async { + // Re-implement this for particular subclass + return false; + } +} + + +/* + * Barcode handler for scanning a provided StockItem into a scanned StockLocation. + * + * - The class is initialized by passing a valid StockItem object + * - Expects to scan barcode for a StockLocation + * - The StockItem is transferred into the scanned location + */ +class StockItemScanIntoLocationHandler extends BarcodeScanStockLocationHandler { + + StockItemScanIntoLocationHandler(this.item); + + final InvenTreeStockItem item; + + @override + Future onLocationScanned(int locationId) async { + + final bool confirm = await InvenTreeSettingsManager().getBool(INV_STOCK_CONFIRM_SCAN, false); + + bool result = false; + + if (confirm) { + + Map fields = item.transferFields(); + + // Override location with scanned value + fields["location"]?["value"] = locationId; + + launchApiForm( + OneContext().context!, + L10().transferStock, + InvenTreeStockItem.transferStockUrl(), + fields, + method: "POST", + icon: FontAwesomeIcons.dolly, + onSuccess: (data) async { + showSnackIcon(L10().stockItemUpdated, success: true); + } + ); + + return true; + } else { + result = await item.transferStock(locationId); + } + + if (result) { + barcodeSuccess(L10().barcodeScanIntoLocationSuccess); + } else { + barcodeFailureTone(); + showSnackIcon(L10().barcodeScanIntoLocationFailure, success: false); + } + + return result; + } +} + + +/* + * Barcode handler for scanning stock item(s) into the specified StockLocation. + * + * - The class is initialized by passing a valid StockLocation object + * - Expects to scan a barcode for a StockItem + * - The scanned StockItem is transferred into the provided StockLocation + */ +class StockLocationScanInItemsHandler extends BarcodeScanStockItemHandler { + + StockLocationScanInItemsHandler(this.location); + + final InvenTreeStockLocation location; + + @override + String getOverlayText(BuildContext context) => L10().barcodeScanItem; + + @override + Future onItemScanned(int itemId) async { + + final InvenTreeStockItem? item = await InvenTreeStockItem().get(itemId) as InvenTreeStockItem?; + final bool confirm = await InvenTreeSettingsManager().getBool(INV_STOCK_CONFIRM_SCAN, false); + + bool result = false; + + if (item != null) { + + // Item is already *in* the specified location + if (item.locationId == location.pk) { + barcodeFailureTone(); + showSnackIcon(L10().itemInLocation, success: true); + return false; + } else { + if (confirm) { + Map fields = item.transferFields(); + + // Override location with provided location value + fields["location"]?["value"] = location.pk; + + launchApiForm( + OneContext().context!, + L10().transferStock, + InvenTreeStockItem.transferStockUrl(), + fields, + method: "POST", + icon: FontAwesomeIcons.dolly, + onSuccess: (data) async { + showSnackIcon(L10().stockItemUpdated, success: true); + } + ); + + return true; + + } else { + result = await item.transferStock(location.pk); + + showSnackIcon( + result ? L10().barcodeScanIntoLocationSuccess : L10().barcodeScanIntoLocationFailure, + success: result + ); + } + } + } + + // We always return false here, to ensure the barcode scan dialog remains open + return false; + } +} + + +/* + * Barcode handler class for scanning a StockLocation into another StockLocation + * + * - The class is initialized by passing a valid StockLocation object + * - Expects to scan barcode for another *parent* StockLocation + * - The scanned StockLocation is set as the "parent" of the provided StockLocation + */ +class ScanParentLocationHandler extends BarcodeScanStockLocationHandler { + + ScanParentLocationHandler(this.location); + + final InvenTreeStockLocation location; + + @override + Future onLocationScanned(int locationId) async { + + final response = await location.update( + values: { + "parent": locationId.toString(), + }, + expectedStatusCode: null, + ); + + switch (response.statusCode) { + case 200: + case 201: + barcodeSuccess(L10().barcodeScanIntoLocationSuccess); + return true; + case 400: // Invalid parent location chosen + barcodeFailureTone(); + showSnackIcon(L10().invalidStockLocation, success: false); + return false; + default: + barcodeFailureTone(); + showSnackIcon( + L10().barcodeScanIntoLocationFailure, + success: false, + actionText: L10().details, + onAction: () { + showErrorDialog( + L10().barcodeError, + response: response, + ); + } + ); + return false; + } + } +} diff --git a/lib/inventree/stock.dart b/lib/inventree/stock.dart index 4f455fe8..ba4e8717 100644 --- a/lib/inventree/stock.dart +++ b/lib/inventree/stock.dart @@ -145,6 +145,50 @@ class InvenTreeStockItem extends InvenTreeModel { @override List get rolesRequired => ["stock"]; + // Return a set of fields to transfer this stock item via dialog + Map transferFields() { + Map fields = { + "pk": { + "parent": "items", + "nested": true, + "hidden": true, + "value": pk, + }, + "quantity": { + "parent": "items", + "nested": true, + "value": quantity, + }, + "location": { + "value": locationId, + }, + "status": { + "parent": "items", + "nested": true, + "value": status, + }, + "packaging": { + "parent": "items", + "nested": true, + "value": packaging, + }, + "notes": {}, + }; + + if (isSerialized()) { + // Prevent editing of 'quantity' field if the item is serialized + fields["quantity"]["hidden"] = true; + } + + // Old API does not support these fields + if (!api.supportsStockAdjustExtraFields) { + fields.remove("packaging"); + fields.remove("status"); + } + + return fields; + } + // URLs for performing stock actions static String transferStockUrl() => "stock/transfer/"; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8c0f05c6..2f622ff8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -240,6 +240,12 @@ "configureServer": "Configure server settings", "@configureServer": {}, + "confirmScan": "Confirm Transfer", + "@confirmScan": {}, + + "confirmScanDetail": "Confirm stock transfer details when scanning barcodes", + "@confirmScan": {}, + "connectionRefused": "Connection Refused", "@connectionRefused": {}, diff --git a/lib/preferences.dart b/lib/preferences.dart index 19e4ffbe..4828a7a8 100644 --- a/lib/preferences.dart +++ b/lib/preferences.dart @@ -35,6 +35,7 @@ const String INV_PART_SHOW_BOM = "partShowBom"; // Stock settings const String INV_STOCK_SHOW_HISTORY = "stockShowHistory"; const String INV_STOCK_SHOW_TESTS = "stockShowTests"; +const String INV_STOCK_CONFIRM_SCAN = "stockConfirmScan"; const String INV_REPORT_ERRORS = "reportErrors"; const String INV_STRICT_HTTPS = "strictHttps"; diff --git a/lib/settings/barcode_settings.dart b/lib/settings/barcode_settings.dart index bb202b23..ffcf4042 100644 --- a/lib/settings/barcode_settings.dart +++ b/lib/settings/barcode_settings.dart @@ -110,7 +110,7 @@ class _InvenTreeBarcodeSettingsState extends State { bool partShowBom = true; bool stockShowHistory = false; bool stockShowTests = false; + bool stockConfirmScan = false; @override void initState() { @@ -32,6 +33,7 @@ class _InvenTreePartSettingsState extends State { partShowBom = await InvenTreeSettingsManager().getValue(INV_PART_SHOW_BOM, true) as bool; stockShowHistory = await InvenTreeSettingsManager().getValue(INV_STOCK_SHOW_HISTORY, false) as bool; stockShowTests = await InvenTreeSettingsManager().getValue(INV_STOCK_SHOW_TESTS, true) as bool; + stockConfirmScan = await InvenTreeSettingsManager().getValue(INV_STOCK_CONFIRM_SCAN, false) as bool; if (mounted) { setState(() { @@ -42,7 +44,7 @@ class _InvenTreePartSettingsState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text(L10().part)), + appBar: AppBar(title: Text(L10().partSettings)), body: Container( child: ListView( children: [ @@ -74,6 +76,7 @@ class _InvenTreePartSettingsState extends State { }, ), ), + Divider(), ListTile( title: Text(L10().stockItemHistory), subtitle: Text(L10().stockItemHistoryDetail), @@ -101,6 +104,20 @@ class _InvenTreePartSettingsState extends State { }); }, ), + ), + ListTile( + title: Text(L10().confirmScan), + subtitle: Text(L10().confirmScanDetail), + leading: FaIcon(FontAwesomeIcons.qrcode), + trailing: Switch( + value: stockConfirmScan, + onChanged: (bool value) { + InvenTreeSettingsManager().setValue(INV_STOCK_CONFIRM_SCAN, value); + setState(() { + stockConfirmScan = value; + }); + } + ), ) ] ) diff --git a/lib/widget/stock/location_display.dart b/lib/widget/stock/location_display.dart index f31940c0..ac73c0d2 100644 --- a/lib/widget/stock/location_display.dart +++ b/lib/widget/stock/location_display.dart @@ -6,6 +6,7 @@ import "package:font_awesome_flutter/font_awesome_flutter.dart"; import "package:inventree/app_colors.dart"; import "package:inventree/barcode/barcode.dart"; import "package:inventree/barcode/purchase_order.dart"; +import "package:inventree/barcode/stock.dart"; import "package:inventree/l10.dart"; import "package:inventree/inventree/stock.dart"; diff --git a/lib/widget/stock/stock_detail.dart b/lib/widget/stock/stock_detail.dart index d9b4edf2..ee21b444 100644 --- a/lib/widget/stock/stock_detail.dart +++ b/lib/widget/stock/stock_detail.dart @@ -5,6 +5,7 @@ import "package:font_awesome_flutter/font_awesome_flutter.dart"; import "package:inventree/app_colors.dart"; import "package:inventree/barcode/barcode.dart"; +import "package:inventree/barcode/stock.dart"; import "package:inventree/helpers.dart"; import "package:inventree/l10.dart"; import "package:inventree/api.dart"; @@ -436,44 +437,7 @@ class _StockItemDisplayState extends RefreshableState { */ Future _transferStockDialog(BuildContext context) async { - Map fields = { - "pk": { - "parent": "items", - "nested": true, - "hidden": true, - "value": widget.item.pk, - }, - "quantity": { - "parent": "items", - "nested": true, - "value": widget.item.quantity, - }, - "location": { - "value": widget.item.locationId, - }, - "status": { - "parent": "items", - "nested": true, - "value": widget.item.status, - }, - "packaging": { - "parent": "items", - "nested": true, - "value": widget.item.packaging, - }, - "notes": {}, - }; - - if (widget.item.isSerialized()) { - // Prevent editing of 'quantity' field if the item is serialized - fields["quantity"]["hidden"] = true; - } - - // Old API does not support these fields - if (!api.supportsStockAdjustExtraFields) { - fields.remove("packaging"); - fields.remove("status"); - } + Map fields = widget.item.transferFields(); launchApiForm( context, diff --git a/test/barcode_test.dart b/test/barcode_test.dart index 181e74d2..cd77c57f 100644 --- a/test/barcode_test.dart +++ b/test/barcode_test.dart @@ -8,9 +8,11 @@ import "package:flutter_test/flutter_test.dart"; import "package:inventree/api.dart"; -import "package:inventree/barcode/barcode.dart"; import "package:inventree/helpers.dart"; +import "package:inventree/barcode/barcode.dart"; +import "package:inventree/barcode/stock.dart"; + import "package:inventree/inventree/part.dart"; import "package:inventree/inventree/stock.dart";