mirror of
				https://github.com/inventree/inventree-app.git
				synced 2025-10-31 13:25:40 +00:00 
			
		
		
		
	Stock location scan (#169)
* Add action for scanning a stock location into another location * Adds barcode scan handler for new functionality * Handle scanning of stock location * Cleanup * Refactor existing barcode scanning functions - Will require extensive testing and validation * Add entry to release notes * Delete dead code * Improved ordering based on stock quantity * Bug fix for 'adjustStock' function * Improve error responses for barcode scanning * Improve error responses for barcode scanning * Remove old debug statements * Add some extra explanatory texts * Icon change * Fixes for unit tests * Adds extra functionality for user profile manager * Refactor barcode code - do not rely on BuildContext * Adds initial unit testing for barcode scanning - Work on mocking barcode data - Add hooks for testing snackBar and audio files * Linting fixes * More barcode unit tests * Cleanup unit tests for barcode * Remove unused import * Handle HTTPException in API * Improvements for API unit testing * Unit testing for scanning item into location * Add unit test for scanning in items from a location context * Unit test for scanning location into parent location * Improve feedback for barcode scanning events
This commit is contained in:
		| @@ -8,6 +8,7 @@ | ||||
| - Display Bill of Materials in the part detail view | ||||
| - Indicate available quantity in stock detail view | ||||
| - Adds configurable filtering to various list views | ||||
| - Allow stock location to be "scanned" into another location using barcode | ||||
|  | ||||
| ### 0.7.3 - June 2022 | ||||
| --- | ||||
|   | ||||
| @@ -413,6 +413,8 @@ class InvenTreeAPI { | ||||
|           break; | ||||
|       } | ||||
|  | ||||
|       debug("Token request failed: STATUS ${response.statusCode}"); | ||||
|  | ||||
|       return false; | ||||
|     } | ||||
|  | ||||
| @@ -1017,14 +1019,16 @@ class InvenTreeAPI { | ||||
|         } | ||||
|  | ||||
|         if (statusCode != null) { | ||||
|  | ||||
|           // Expected status code not returned | ||||
|           if (statusCode != _response.statusCode) { | ||||
|             showStatusCodeError(url, _response.statusCode); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|  | ||||
|     } on HttpException catch (error) { | ||||
|       showServerError(url, L10().serverError, error.toString()); | ||||
|       response.error = "HTTPException"; | ||||
|       response.errorDetail = error.toString(); | ||||
|     } on SocketException catch (error) { | ||||
|       showServerError(url, L10().connectionRefused, error.toString()); | ||||
|       response.error = "SocketException"; | ||||
|   | ||||
| @@ -857,6 +857,7 @@ Future<void> launchApiForm( | ||||
|   Map<String, dynamic> serverFields = {}; | ||||
|  | ||||
|   if (url.isNotEmpty) { | ||||
|  | ||||
|     var options = await InvenTreeAPI().options(url); | ||||
|  | ||||
|     // Invalid response from server | ||||
|   | ||||
							
								
								
									
										419
									
								
								lib/barcode.dart
									
									
									
									
									
								
							
							
						
						
									
										419
									
								
								lib/barcode.dart
									
									
									
									
									
								
							| @@ -43,14 +43,13 @@ Future <void> barcodeFailureTone() async { | ||||
| } | ||||
|  | ||||
|  | ||||
| /* Generic class which "handles" a barcode, by communicating with the InvenTree server, | ||||
|  * and handling match / unknown / error cases. | ||||
|  * | ||||
|  * Override functionality of this class to perform custom actions, | ||||
|  * based on the response returned from the InvenTree server | ||||
|  */ | ||||
| class BarcodeHandler { | ||||
|   /* | ||||
|    * Class which "handles" a barcode, by communicating with the InvenTree server, | ||||
|    * and handling match / unknown / error cases. | ||||
|    * | ||||
|    * Override functionality of this class to perform custom actions, | ||||
|    * based on the response returned from the InvenTree server | ||||
|    */ | ||||
|  | ||||
|   BarcodeHandler(); | ||||
|  | ||||
| @@ -58,12 +57,12 @@ class BarcodeHandler { | ||||
|  | ||||
|   QRViewController? _controller; | ||||
|  | ||||
|     Future<void> onBarcodeMatched(BuildContext context, Map<String, dynamic> data) async { | ||||
|     Future<void> onBarcodeMatched(Map<String, dynamic> data) async { | ||||
|       // Called when the server "matches" a barcode | ||||
|       // Override this function | ||||
|     } | ||||
|  | ||||
|     Future<void> onBarcodeUnknown(BuildContext context, Map<String, dynamic> data) async { | ||||
|     Future<void> onBarcodeUnknown(Map<String, dynamic> data) async { | ||||
|       // Called when the server does not know about a barcode | ||||
|       // Override this function | ||||
|  | ||||
| @@ -76,7 +75,7 @@ class BarcodeHandler { | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     Future<void> onBarcodeUnhandled(BuildContext context, Map<String, dynamic> data) async { | ||||
|     Future<void> onBarcodeUnhandled(Map<String, dynamic> data) async { | ||||
|  | ||||
|       barcodeFailureTone(); | ||||
|  | ||||
| @@ -86,12 +85,27 @@ class BarcodeHandler { | ||||
|       _controller?.resumeCamera(); | ||||
|     } | ||||
|  | ||||
|     Future<void> processBarcode(BuildContext context, QRViewController? _controller, String barcode, {String url = "barcode/"}) async { | ||||
|     /* | ||||
|      * Base function to capture and process barcode data. | ||||
|      */ | ||||
|     Future<void> processBarcode(QRViewController? _controller, String barcode, {String url = "barcode/"}) async { | ||||
|       this._controller = _controller; | ||||
|  | ||||
|       print("Scanned barcode data: ${barcode}"); | ||||
|       debug("Scanned barcode data: '${barcode}'"); | ||||
|  | ||||
|       barcode = barcode.trim(); | ||||
|  | ||||
|       // Empty barcode is invalid | ||||
|       if (barcode.isEmpty) { | ||||
|  | ||||
|         barcodeFailureTone(); | ||||
|  | ||||
|         showSnackIcon( | ||||
|           L10().barcodeError, | ||||
|           icon: FontAwesomeIcons.exclamationCircle, | ||||
|           success: false | ||||
|         ); | ||||
|  | ||||
|         return; | ||||
|       } | ||||
|  | ||||
| @@ -109,7 +123,9 @@ class BarcodeHandler { | ||||
|  | ||||
|       // Handle strange response from the server | ||||
|       if (!response.isValid() || !response.isMap()) { | ||||
|         onBarcodeUnknown(context, {}); | ||||
|         onBarcodeUnknown({}); | ||||
|  | ||||
|         showSnackIcon(L10().serverError, success: false); | ||||
|  | ||||
|         // We want to know about this one! | ||||
|         await sentryReportMessage( | ||||
| @@ -122,31 +138,36 @@ class BarcodeHandler { | ||||
|               "valid": response.isValid().toString(), | ||||
|               "error": response.error, | ||||
|               "errorDetail": response.errorDetail, | ||||
|               "overlayText": getOverlayText(context), | ||||
|               "className": "${this}", | ||||
|             } | ||||
|         ); | ||||
|       } else if ((response.statusCode >= 400) || data.containsKey("error")) { | ||||
|         onBarcodeUnknown(context, data); | ||||
|       } else if (data.containsKey("success")) { | ||||
|         onBarcodeMatched(context, data); | ||||
|         await onBarcodeMatched(data); | ||||
|       } else if ((response.statusCode >= 400) || data.containsKey("error")) { | ||||
|         await onBarcodeUnknown(data); | ||||
|       } else { | ||||
|         onBarcodeUnhandled(context, data); | ||||
|         await onBarcodeUnhandled(data); | ||||
|       } | ||||
|     } | ||||
| } | ||||
|  | ||||
|  | ||||
| /* | ||||
|  * Class for general barcode scanning. | ||||
|  * Scan *any* barcode without context, and then redirect app to correct view. | ||||
|  * | ||||
|  * Handles scanning of: | ||||
|  * | ||||
|  * - StockLocation | ||||
|  * - StockItem | ||||
|  * - Part | ||||
|  */ | ||||
| class BarcodeScanHandler extends BarcodeHandler { | ||||
|   /* | ||||
|    * Class for general barcode scanning. | ||||
|    * Scan *any* barcode without context, and then redirect app to correct view | ||||
|    */ | ||||
|  | ||||
|   @override | ||||
|   String getOverlayText(BuildContext context) => L10().barcodeScanGeneral; | ||||
|  | ||||
|   @override | ||||
|   Future<void> onBarcodeUnknown(BuildContext context, Map<String, dynamic> data) async { | ||||
|   Future<void> onBarcodeUnknown(Map<String, dynamic> data) async { | ||||
|  | ||||
|     barcodeFailureTone(); | ||||
|  | ||||
| @@ -158,7 +179,7 @@ class BarcodeScanHandler extends BarcodeHandler { | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<void> onBarcodeMatched(BuildContext context, Map<String, dynamic> data) async { | ||||
|   Future<void> onBarcodeMatched(Map<String, dynamic> data) async { | ||||
|  | ||||
|     int pk = -1; | ||||
|  | ||||
| @@ -173,8 +194,13 @@ class BarcodeScanHandler extends BarcodeHandler { | ||||
|  | ||||
|         InvenTreeStockLocation().get(pk).then((var loc) { | ||||
|           if (loc is InvenTreeStockLocation) { | ||||
|               Navigator.of(context).pop(); | ||||
|               Navigator.push(context, MaterialPageRoute(builder: (context) => LocationDisplayWidget(loc))); | ||||
|             showSnackIcon( | ||||
|               L10().stockLocation, | ||||
|               success: true, | ||||
|               icon: Icons.qr_code, | ||||
|             ); | ||||
|             OneContext().pop(); | ||||
|             OneContext().navigator.push(MaterialPageRoute(builder: (context) => LocationDisplayWidget(loc))); | ||||
|           } | ||||
|         }); | ||||
|       } else { | ||||
| @@ -196,13 +222,15 @@ class BarcodeScanHandler extends BarcodeHandler { | ||||
|         barcodeSuccessTone(); | ||||
|  | ||||
|         InvenTreeStockItem().get(pk).then((var item) { | ||||
|  | ||||
|             // Dispose of the barcode scanner | ||||
|             Navigator.of(context).pop(); | ||||
|  | ||||
|             if (item is InvenTreeStockItem) { | ||||
|               Navigator.push(context, MaterialPageRoute(builder: (context) => StockDetailWidget(item))); | ||||
|             } | ||||
|           showSnackIcon( | ||||
|             L10().stockItem, | ||||
|             success: true, | ||||
|             icon: Icons.qr_code, | ||||
|           ); | ||||
|           OneContext().pop(); | ||||
|           if (item is InvenTreeStockItem) { | ||||
|             OneContext().push(MaterialPageRoute(builder: (context) => StockDetailWidget(item))); | ||||
|           } | ||||
|         }); | ||||
|       } else { | ||||
|  | ||||
| @@ -222,13 +250,17 @@ class BarcodeScanHandler extends BarcodeHandler { | ||||
|         barcodeSuccessTone(); | ||||
|  | ||||
|         InvenTreePart().get(pk).then((var part) { | ||||
|           showSnackIcon( | ||||
|             L10().part, | ||||
|             success: true, | ||||
|             icon: Icons.qr_code, | ||||
|           ); | ||||
|           // Dismiss the barcode scanner | ||||
|           OneContext().pop(); | ||||
|  | ||||
|             // Dismiss the barcode scanner | ||||
|             Navigator.of(context).pop(); | ||||
|  | ||||
|             if (part is InvenTreePart) { | ||||
|               Navigator.push(context, MaterialPageRoute(builder: (context) => PartDetailWidget(part))); | ||||
|             } | ||||
|           if (part is InvenTreePart) { | ||||
|             OneContext().push(MaterialPageRoute(builder: (context) => PartDetailWidget(part))); | ||||
|           } | ||||
|         }); | ||||
|       } else { | ||||
|  | ||||
| @@ -265,73 +297,148 @@ class BarcodeScanHandler extends BarcodeHandler { | ||||
|   } | ||||
| } | ||||
|  | ||||
| class StockItemScanIntoLocationHandler extends BarcodeHandler { | ||||
|   /* | ||||
|    * Barcode handler for scanning a provided StockItem into a scanned StockLocation | ||||
|    */ | ||||
|  | ||||
| /* | ||||
|  * 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<void> onBarcodeMatched(Map<String, dynamic> 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<bool> 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<void> onBarcodeMatched(Map<String, dynamic> 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(); | ||||
|  | ||||
|         final 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<bool> 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 | ||||
|   String getOverlayText(BuildContext context) => L10().barcodeScanLocation; | ||||
|   Future<bool> onLocationScanned(int locationId) async { | ||||
|  | ||||
|   @override | ||||
|   Future<void> onBarcodeMatched(BuildContext context, Map<String, dynamic> data) async { | ||||
|     // If the barcode points to a "stocklocation", great! | ||||
|     if (data.containsKey("stocklocation")) { | ||||
|       // Extract location information | ||||
|       int location = (data["stocklocation"]["pk"] ?? -1) as int; | ||||
|     final result = await item.transferStock(locationId); | ||||
|  | ||||
|       if (location == -1) { | ||||
|         showSnackIcon( | ||||
|           L10().invalidStockLocation, | ||||
|           success: false, | ||||
|         ); | ||||
|  | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // Transfer stock to specified location | ||||
|       final result = await item.transferStock(context, location); | ||||
|  | ||||
|       if (result) { | ||||
|  | ||||
|         barcodeSuccessTone(); | ||||
|  | ||||
|         Navigator.of(context).pop(); | ||||
|  | ||||
|         showSnackIcon( | ||||
|           L10().barcodeScanIntoLocationSuccess, | ||||
|           success: true, | ||||
|         ); | ||||
|       } else { | ||||
|  | ||||
|         barcodeFailureTone(); | ||||
|  | ||||
|         showSnackIcon( | ||||
|           L10().barcodeScanIntoLocationFailure, | ||||
|           success: false | ||||
|         ); | ||||
|       } | ||||
|     if (result) { | ||||
|       barcodeSuccessTone(); | ||||
|       showSnackIcon(L10().barcodeScanIntoLocationSuccess, success: true); | ||||
|     } else { | ||||
|  | ||||
|       barcodeFailureTone(); | ||||
|  | ||||
|       showSnackIcon( | ||||
|         L10().invalidStockLocation, | ||||
|         success: false, | ||||
|       ); | ||||
|       showSnackIcon(L10().barcodeScanIntoLocationFailure, success: false); | ||||
|     } | ||||
|  | ||||
|     return result; | ||||
|   } | ||||
| } | ||||
|  | ||||
|  | ||||
| class StockLocationScanInItemsHandler extends BarcodeHandler { | ||||
|   /* | ||||
|    * Barcode handler for scanning stock item(s) into the specified StockLocation | ||||
|    */ | ||||
| /* | ||||
|  * 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); | ||||
|  | ||||
| @@ -341,69 +448,91 @@ class StockLocationScanInItemsHandler extends BarcodeHandler { | ||||
|   String getOverlayText(BuildContext context) => L10().barcodeScanItem; | ||||
|  | ||||
|   @override | ||||
|   Future<void> onBarcodeMatched(BuildContext context, Map<String, dynamic> data) async { | ||||
|   Future<bool> onItemScanned(int itemId) async { | ||||
|  | ||||
|     // Returned barcode must match a stock item | ||||
|     if (data.containsKey("stockitem")) { | ||||
|     final InvenTreeStockItem? item = await InvenTreeStockItem().get(itemId) as InvenTreeStockItem?; | ||||
|  | ||||
|       int item_id = data["stockitem"]["pk"] as int; | ||||
|     bool result = false; | ||||
|  | ||||
|       final InvenTreeStockItem? item = await InvenTreeStockItem().get(item_id) as InvenTreeStockItem?; | ||||
|  | ||||
|       if (item == null) { | ||||
|     if (item != null) { | ||||
|  | ||||
|       // Item is already *in* the specified location | ||||
|       if (item.locationId == location.pk) { | ||||
|         barcodeFailureTone(); | ||||
|  | ||||
|         showSnackIcon( | ||||
|           L10().invalidStockItem, | ||||
|           success: false, | ||||
|         ); | ||||
|       } else if (item.locationId == location.pk) { | ||||
|         barcodeFailureTone(); | ||||
|  | ||||
|         showSnackIcon( | ||||
|             L10().itemInLocation, | ||||
|             success: true | ||||
|         ); | ||||
|         showSnackIcon(L10().itemInLocation, success: true); | ||||
|         return false; | ||||
|       } else { | ||||
|         final result = await item.transferStock(context, location.pk); | ||||
|  | ||||
|         if (result) { | ||||
|  | ||||
|           barcodeSuccessTone(); | ||||
|  | ||||
|           showSnackIcon( | ||||
|             L10().barcodeScanIntoLocationSuccess, | ||||
|             success: true | ||||
|           ); | ||||
|         } else { | ||||
|  | ||||
|           barcodeFailureTone(); | ||||
|  | ||||
|           showSnackIcon( | ||||
|             L10().barcodeScanIntoLocationFailure, | ||||
|             success: false | ||||
|           ); | ||||
|         } | ||||
|         result = await item.transferStock(location.pk); | ||||
|       } | ||||
|     } else { | ||||
|     } | ||||
|  | ||||
|       barcodeFailureTone(); | ||||
|     showSnackIcon( | ||||
|       result ? L10().barcodeScanIntoLocationSuccess : L10().barcodeScanIntoLocationFailure, | ||||
|       success: result | ||||
|     ); | ||||
|  | ||||
|       // Does not match a valid stock item! | ||||
|       showSnackIcon( | ||||
|         L10().invalidStockItem, | ||||
|         success: false, | ||||
|       ); | ||||
|     // 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<bool> 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) | ||||
|  */ | ||||
| class UniqueBarcodeHandler extends BarcodeHandler { | ||||
|   /* | ||||
|    * Barcode handler for finding a "unique" barcode (one that does not match an item in the database) | ||||
|    */ | ||||
|  | ||||
|   UniqueBarcodeHandler(this.callback, {this.overlayText = ""}); | ||||
|  | ||||
| @@ -422,7 +551,7 @@ class UniqueBarcodeHandler extends BarcodeHandler { | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<void> onBarcodeMatched(BuildContext context, Map<String, dynamic> data) async { | ||||
|   Future<void> onBarcodeMatched(Map<String, dynamic> data) async { | ||||
|  | ||||
|     barcodeFailureTone(); | ||||
|  | ||||
| @@ -435,7 +564,7 @@ class UniqueBarcodeHandler extends BarcodeHandler { | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<void> onBarcodeUnknown(BuildContext context, Map<String, dynamic> data) async { | ||||
|   Future<void> onBarcodeUnknown(Map<String, dynamic> data) async { | ||||
|     // If the barcode is unknown, we *can* assign it to the stock item! | ||||
|  | ||||
|     if (!data.containsKey("hash")) { | ||||
| @@ -459,7 +588,9 @@ class UniqueBarcodeHandler extends BarcodeHandler { | ||||
|         barcodeSuccessTone(); | ||||
|  | ||||
|         // Close the barcode scanner | ||||
|         Navigator.of(context).pop(); | ||||
|         if (OneContext.hasContext) { | ||||
|           OneContext().pop(); | ||||
|         } | ||||
|  | ||||
|         callback(hash); | ||||
|       } | ||||
| @@ -521,7 +652,7 @@ class _QRViewState extends State<InvenTreeQRView> { | ||||
|       _controller?.pauseCamera(); | ||||
|  | ||||
|       if (barcode.code != null) { | ||||
|         _handler.processBarcode(context, _controller, barcode.code ?? ""); | ||||
|         _handler.processBarcode(_controller, barcode.code ?? ""); | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|   | ||||
| @@ -13,12 +13,37 @@ import "package:audioplayers/audioplayers.dart"; | ||||
| import "package:one_context/one_context.dart"; | ||||
|  | ||||
|  | ||||
| List<String> debug_messages = []; | ||||
|  | ||||
| void clearDebugMessage() => debug_messages.clear(); | ||||
|  | ||||
| int debugMessageCount() => debug_messages.length; | ||||
|  | ||||
| // Check if the debug log contains a given message | ||||
| bool debugContains(String msg, {bool raiseAssert = true}) { | ||||
|   bool result = false; | ||||
|  | ||||
|   for (String element in debug_messages) { | ||||
|     if (element.contains(msg)) { | ||||
|       result = true; | ||||
|       break; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (raiseAssert) { | ||||
|     assert(result); | ||||
|   } | ||||
|  | ||||
|   return result; | ||||
| } | ||||
|  | ||||
| /* | ||||
|  * Display a debug message if we are in testing mode, or running in debug mode | ||||
|  */ | ||||
| void debug(dynamic msg) { | ||||
|  | ||||
|   if (Platform.environment.containsKey("FLUTTER_TEST")) { | ||||
|     debug_messages.add(msg.toString()); | ||||
|     print("DEBUG: ${msg.toString()}"); | ||||
|   } | ||||
| } | ||||
| @@ -38,11 +63,13 @@ String simpleNumberString(double number) { | ||||
|  */ | ||||
| Future<void> playAudioFile(String path) async { | ||||
|  | ||||
|   // Debug message for unit testing | ||||
|   debug("Playing audio file: '${path}'"); | ||||
|  | ||||
|   if (!OneContext.hasContext) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   final player = AudioCache(); | ||||
|   player.play(path); | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -107,6 +107,9 @@ class InvenTreeModel { | ||||
|  | ||||
|   } | ||||
|  | ||||
|   /* | ||||
|    * Launch a modal form to edit the fields available to this model instance. | ||||
|    */ | ||||
|   Future<void> editForm(BuildContext context, String title, {Map<String, dynamic> fields=const {}, Function(dynamic)? onSuccess}) async { | ||||
|  | ||||
|     if (fields.isEmpty) { | ||||
| @@ -317,7 +320,7 @@ class InvenTreeModel { | ||||
|   } | ||||
|  | ||||
|   // POST data to update the model | ||||
|   Future<bool> update({Map<String, String> values = const {}}) async { | ||||
|   Future<APIResponse> update({Map<String, String> values = const {}, int? expectedStatusCode = 200}) async { | ||||
|  | ||||
|     var url = path.join(URL, pk.toString()); | ||||
|  | ||||
| @@ -325,17 +328,13 @@ class InvenTreeModel { | ||||
|       url += "/"; | ||||
|     } | ||||
|  | ||||
|     var response = await api.patch( | ||||
|     final response = await api.patch( | ||||
|       url, | ||||
|       body: values, | ||||
|       expectedStatusCode: 200 | ||||
|       expectedStatusCode: expectedStatusCode, | ||||
|     ); | ||||
|  | ||||
|     if (!response.isValid()) { | ||||
|       return false; | ||||
|     } | ||||
|  | ||||
|     return true; | ||||
|     return response; | ||||
|   } | ||||
|  | ||||
|   // Return the detail view for the associated pk | ||||
|   | ||||
| @@ -43,8 +43,8 @@ class InvenTreePartCategory extends InvenTreeModel { | ||||
|  | ||||
|   String get pathstring => (jsondata["pathstring"] ?? "") as String; | ||||
|  | ||||
|   String get parentpathstring { | ||||
|     // TODO - Drive the refactor tractor through this | ||||
|   String get parentPathString { | ||||
|  | ||||
|     List<String> psplit = pathstring.split("/"); | ||||
|  | ||||
|     if (psplit.isNotEmpty) { | ||||
|   | ||||
| @@ -535,7 +535,7 @@ class InvenTreeStockItem extends InvenTreeModel { | ||||
|    * - Remove | ||||
|    * - Count | ||||
|    */ | ||||
|   Future<bool> adjustStock(BuildContext context, String endpoint, double q, {String? notes, int? location}) async { | ||||
|   Future<bool> adjustStock(String endpoint, double q, {String? notes, int? location}) async { | ||||
|  | ||||
|     // Serialized stock cannot be adjusted (unless it is a "transfer") | ||||
|     if (isSerialized() && location == null) { | ||||
| @@ -566,34 +566,33 @@ class InvenTreeStockItem extends InvenTreeModel { | ||||
|     var response = await api.post( | ||||
|       endpoint, | ||||
|       body: data, | ||||
|       expectedStatusCode: 200, | ||||
|     ); | ||||
|  | ||||
|     return response.isValid(); | ||||
|     return response.isValid() && (response.statusCode == 200 || response.statusCode == 201); | ||||
|   } | ||||
|  | ||||
|   Future<bool> countStock(BuildContext context, double q, {String? notes}) async { | ||||
|   Future<bool> countStock(double q, {String? notes}) async { | ||||
|  | ||||
|     final bool result = await adjustStock(context, "/stock/count/", q, notes: notes); | ||||
|     final bool result = await adjustStock("/stock/count/", q, notes: notes); | ||||
|  | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   Future<bool> addStock(BuildContext context, double q, {String? notes}) async { | ||||
|   Future<bool> addStock(double q, {String? notes}) async { | ||||
|  | ||||
|     final bool result = await adjustStock(context,  "/stock/add/", q, notes: notes); | ||||
|     final bool result = await adjustStock("/stock/add/", q, notes: notes); | ||||
|  | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   Future<bool> removeStock(BuildContext context, double q, {String? notes}) async { | ||||
|   Future<bool> removeStock(double q, {String? notes}) async { | ||||
|  | ||||
|     final bool result = await adjustStock(context, "/stock/remove/", q, notes: notes); | ||||
|     final bool result = await adjustStock("/stock/remove/", q, notes: notes); | ||||
|  | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   Future<bool> transferStock(BuildContext context, int location, {double? quantity, String? notes}) async { | ||||
|   Future<bool> transferStock(int location, {double? quantity, String? notes}) async { | ||||
|  | ||||
|     double q = this.quantity; | ||||
|  | ||||
| @@ -602,7 +601,6 @@ class InvenTreeStockItem extends InvenTreeModel { | ||||
|     } | ||||
|  | ||||
|     final bool result = await adjustStock( | ||||
|       context, | ||||
|       "/stock/transfer/", | ||||
|       q, | ||||
|       notes: notes, | ||||
| @@ -653,12 +651,14 @@ class InvenTreeStockLocation extends InvenTreeModel { | ||||
|     return { | ||||
|       "name": {}, | ||||
|       "description": {}, | ||||
|       "parent": {}, | ||||
|       "parent": { | ||||
|  | ||||
|       }, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   String get parentpathstring { | ||||
|     // TODO - Drive the refactor tractor through this | ||||
|   String get parentPathString { | ||||
|  | ||||
|     List<String> psplit = pathstring.split("/"); | ||||
|  | ||||
|     if (psplit.isNotEmpty) { | ||||
|   | ||||
| @@ -82,6 +82,9 @@ | ||||
|   "barcodeAssign": "Assign Barcode", | ||||
|   "@barcodeAssign": {}, | ||||
|  | ||||
|   "barcodeAssignDetail": "Scan custom barcode to assign", | ||||
|   "@barcodeAssignDetail": {}, | ||||
|  | ||||
|   "barcodeAssigned": "Barcode assigned", | ||||
|   "@barcodeAssigned": {}, | ||||
|  | ||||
| @@ -106,7 +109,7 @@ | ||||
|   "barcodeScanGeneral":  "Scan an InvenTree barcode", | ||||
|   "@barcodeScanGeneral": {}, | ||||
|  | ||||
|   "barcodeScanInItems": "Scan stock items into location", | ||||
|   "barcodeScanInItems": "Scan stock items into this location", | ||||
|   "@barcodeScanInItems": {}, | ||||
|  | ||||
|   "barcodeScanLocation": "Scan stock location", | ||||
| @@ -807,6 +810,9 @@ | ||||
|   "scanIntoLocation": "Scan Into Location", | ||||
|   "@scanIntoLocation": {}, | ||||
|  | ||||
|   "scanIntoLocationDetail": "Scan this item into location", | ||||
|   "@scanIntoLocationDetail": {}, | ||||
|  | ||||
|   "search": "Search", | ||||
|   "@search": { | ||||
|     "description": "search" | ||||
| @@ -1087,6 +1093,15 @@ | ||||
|     "description": "transfer stock" | ||||
|   }, | ||||
|  | ||||
|   "transferStockDetail": "Transfer item to a different location", | ||||
|   "@transferStockDetail": {}, | ||||
|  | ||||
|   "transferStockLocation": "Transfer Stock Location", | ||||
|   "@transferStockLocation": {}, | ||||
|  | ||||
|   "transferStockLocationDetail": "Transfer this stock location into another", | ||||
|   "@transferStockLocationDetail": {}, | ||||
|  | ||||
|   "translate": "Translate", | ||||
|   "@translate": {}, | ||||
|  | ||||
|   | ||||
| @@ -57,6 +57,9 @@ class UserProfile { | ||||
|   } | ||||
| } | ||||
|  | ||||
| /* | ||||
|  * Class for storing and managing user (server) profiles | ||||
|  */ | ||||
| class UserProfileDBManager { | ||||
|  | ||||
|   final store = StoreRef("profiles"); | ||||
| @@ -96,6 +99,8 @@ class UserProfileDBManager { | ||||
|     if (exists) { | ||||
|       debug("addProfile() : UserProfile '${profile.name}' already exists"); | ||||
|       return false; | ||||
|     } else { | ||||
|       debug("Adding new profile: '${profile.name}'"); | ||||
|     } | ||||
|  | ||||
|     int key = await store.add(await _db, profile.toJson()) as int; | ||||
| @@ -149,8 +154,6 @@ class UserProfileDBManager { | ||||
|  | ||||
|     for (int idx = 0; idx < profiles.length; idx++) { | ||||
|  | ||||
|       debug("- Checking ${idx} - key = ${profiles[idx].key} - ${profiles[idx].value.toString()}"); | ||||
|  | ||||
|       if (profiles[idx].key is int && profiles[idx].key == selected) { | ||||
|         return UserProfile.fromJson( | ||||
|           profiles[idx].key as int, | ||||
| @@ -190,6 +193,24 @@ class UserProfileDBManager { | ||||
|     return profileList; | ||||
|   } | ||||
|  | ||||
|   /* | ||||
|    * Retrieve a profile by name (or null if no match exists) | ||||
|    */ | ||||
|   Future<UserProfile?> getProfileByName(String name) async { | ||||
|     final profiles = await getAllProfiles(); | ||||
|  | ||||
|     UserProfile? prf; | ||||
|  | ||||
|     for (UserProfile profile in profiles) { | ||||
|       if (profile.name == name) { | ||||
|         prf = profile; | ||||
|         break; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return prf; | ||||
|   } | ||||
|  | ||||
|   /* | ||||
|    * Mark the particular profile as selected | ||||
|    */ | ||||
|   | ||||
| @@ -131,7 +131,7 @@ class _CategoryDisplayState extends RefreshableState<CategoryDisplayWidget> { | ||||
|         children.add( | ||||
|             ListTile( | ||||
|               title: Text(L10().parentCategory), | ||||
|               subtitle: Text("${category?.parentpathstring}"), | ||||
|               subtitle: Text("${category?.parentPathString}"), | ||||
|               leading: FaIcon( | ||||
|                 FontAwesomeIcons.levelUpAlt, | ||||
|                 color: COLOR_CLICK, | ||||
|   | ||||
| @@ -3,11 +3,15 @@ import "package:font_awesome_flutter/font_awesome_flutter.dart"; | ||||
| import "package:inventree/helpers.dart"; | ||||
| import "package:one_context/one_context.dart"; | ||||
|  | ||||
| import "package:inventree/api.dart"; | ||||
| import "package:inventree/l10.dart"; | ||||
| import "package:inventree/preferences.dart"; | ||||
| import "package:inventree/widget/snacks.dart"; | ||||
|  | ||||
|  | ||||
| /* | ||||
|  * Display a "confirmation" dialog allowing the user to accept or reject an action | ||||
|  */ | ||||
| Future<void> confirmationDialog(String title, String text, {IconData icon = FontAwesomeIcons.questionCircle, String? acceptText, String? rejectText, Function? onAccept, Function? onReject}) async { | ||||
|  | ||||
|   String _accept = acceptText ?? L10().ok; | ||||
| @@ -51,22 +55,86 @@ Future<void> confirmationDialog(String title, String text, {IconData icon = Font | ||||
| } | ||||
|  | ||||
|  | ||||
| Future<void> showErrorDialog(String title, String description, {IconData icon = FontAwesomeIcons.exclamationCircle, String? error, Function? onDismissed}) async { | ||||
| /* | ||||
|  * Construct an error dialog showing information to the user | ||||
|  * | ||||
|  * @title = Title to be displayed at the top of the dialog | ||||
|  * @description = Simple string description of error | ||||
|  * @data = Error response (e.g from server) | ||||
|  */ | ||||
| Future<void> showErrorDialog(String title, {String description = "", APIResponse? response, IconData icon = FontAwesomeIcons.exclamationCircle, Function? onDismissed}) async { | ||||
|  | ||||
|   String _error = error ?? L10().error; | ||||
|   List<Widget> children = []; | ||||
|  | ||||
|   if (description.isNotEmpty) { | ||||
|     children.add( | ||||
|       ListTile( | ||||
|         title: Text(description), | ||||
|       ) | ||||
|     ); | ||||
|   } else if (response != null) { | ||||
|     // Look for extra error information in the provided APIResponse object | ||||
|     switch (response.statusCode) { | ||||
|       case 400:  // Bad request (typically bad input) | ||||
|         if (response.data is Map<String, dynamic>) { | ||||
|           for (String field in response.data.keys) { | ||||
|  | ||||
|             dynamic error = response.data[field]; | ||||
|  | ||||
|             if (error is List) { | ||||
|               for (int ii = 0; ii < error.length; ii++) { | ||||
|                 children.add( | ||||
|                   ListTile( | ||||
|                     title: Text(field), | ||||
|                     subtitle: Text(error[ii].toString()), | ||||
|                   ) | ||||
|                 ); | ||||
|               } | ||||
|             } else { | ||||
|               children.add( | ||||
|                   ListTile( | ||||
|                     title: Text(field), | ||||
|                     subtitle: Text(response.data[field].toString()), | ||||
|                   ) | ||||
|               ); | ||||
|             } | ||||
|           } | ||||
|         } else { | ||||
|           children.add( | ||||
|             ListTile( | ||||
|               title: Text(L10().responseInvalid), | ||||
|               subtitle: Text(response.data.toString()) | ||||
|             ) | ||||
|           ); | ||||
|         } | ||||
|         break; | ||||
|       default: | ||||
|         // Unhandled server response | ||||
|         children.add( | ||||
|           ListTile( | ||||
|             title: Text(L10().statusCode), | ||||
|             subtitle: Text(response.statusCode.toString()), | ||||
|           ) | ||||
|         ); | ||||
|  | ||||
|         children.add( | ||||
|           ListTile( | ||||
|             title: Text(L10().responseData), | ||||
|             subtitle: Text(response.data.toString()), | ||||
|           ) | ||||
|         ); | ||||
|  | ||||
|         break; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   OneContext().showDialog( | ||||
|     builder: (context) => SimpleDialog( | ||||
|       title: ListTile( | ||||
|         title: Text(_error), | ||||
|         title: Text(title), | ||||
|         leading: FaIcon(icon), | ||||
|       ), | ||||
|       children: [ | ||||
|         ListTile( | ||||
|           title: Text(title), | ||||
|           subtitle: Text(description), | ||||
|         ) | ||||
|       ], | ||||
|       children: children | ||||
|     ) | ||||
|   ).then((value) { | ||||
|     if (onDismissed != null) { | ||||
| @@ -106,9 +174,8 @@ Future<void> showServerError(String url, String title, String description) async | ||||
|     actionText: L10().details, | ||||
|     onAction: () { | ||||
|       showErrorDialog( | ||||
|           title, | ||||
|           description, | ||||
|           error: L10().serverError, | ||||
|           L10().serverError, | ||||
|           description: description, | ||||
|           icon: FontAwesomeIcons.server | ||||
|       ); | ||||
|     } | ||||
|   | ||||
| @@ -40,27 +40,6 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> { | ||||
|  | ||||
|     List<Widget> actions = []; | ||||
|  | ||||
|     /* | ||||
|     actions.add( | ||||
|       IconButton( | ||||
|         icon: FaIcon(FontAwesomeIcons.search), | ||||
|         onPressed: () { | ||||
|  | ||||
|           Map<String, String> filters = {}; | ||||
|  | ||||
|           if (location != null) { | ||||
|             filters["location"] = "${location.pk}"; | ||||
|           } | ||||
|  | ||||
|           showSearch( | ||||
|             context: context, | ||||
|             delegate: StockSearchDelegate(context, filters: filters) | ||||
|           ); | ||||
|         } | ||||
|       ), | ||||
|     ); | ||||
|      */ | ||||
|  | ||||
|     if (location != null) { | ||||
|  | ||||
|       // Add "locate" button | ||||
| @@ -252,7 +231,7 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> { | ||||
|         children.add( | ||||
|             ListTile( | ||||
|               title: Text(L10().parentLocation), | ||||
|               subtitle: Text("${location!.parentpathstring}"), | ||||
|               subtitle: Text("${location!.parentPathString}"), | ||||
|               leading: FaIcon(FontAwesomeIcons.levelUpAlt, color: COLOR_CLICK), | ||||
|               onTap: () { | ||||
|  | ||||
| @@ -381,6 +360,7 @@ List<Widget> detailTiles() { | ||||
|           title: Text(L10().locationCreate), | ||||
|           subtitle: Text(L10().locationCreateDetail), | ||||
|           leading: FaIcon(FontAwesomeIcons.sitemap, color: COLOR_CLICK), | ||||
|           trailing: FaIcon(FontAwesomeIcons.plusCircle, color: COLOR_CLICK), | ||||
|           onTap: () async { | ||||
|             _newLocation(context); | ||||
|           }, | ||||
| @@ -392,6 +372,7 @@ List<Widget> detailTiles() { | ||||
|           title: Text(L10().stockItemCreate), | ||||
|           subtitle: Text(L10().stockItemCreateDetail), | ||||
|           leading: FaIcon(FontAwesomeIcons.boxes, color: COLOR_CLICK), | ||||
|           trailing: FaIcon(FontAwesomeIcons.plusCircle, color: COLOR_CLICK), | ||||
|           onTap: () async { | ||||
|             _newStockItem(context); | ||||
|           }, | ||||
| @@ -401,14 +382,15 @@ List<Widget> detailTiles() { | ||||
|     } | ||||
|  | ||||
|     if (location != null) { | ||||
|       // Stock adjustment actions | ||||
|  | ||||
|       // Scan stock item into location | ||||
|       if (InvenTreeAPI().checkPermission("stock", "change")) { | ||||
|         // Scan items into location | ||||
|         tiles.add( | ||||
|             ListTile( | ||||
|               title: Text(L10().barcodeScanInItems), | ||||
|               title: Text(L10().barcodeScanItem), | ||||
|               subtitle: Text(L10().barcodeScanInItems), | ||||
|               leading: FaIcon(FontAwesomeIcons.exchangeAlt, color: COLOR_CLICK), | ||||
|               trailing: Icon(Icons.qr_code), | ||||
|               trailing: Icon(Icons.qr_code, color: COLOR_CLICK), | ||||
|               onTap: () { | ||||
|  | ||||
|                 var _loc = location; | ||||
| @@ -426,21 +408,35 @@ List<Widget> detailTiles() { | ||||
|               }, | ||||
|             ) | ||||
|         ); | ||||
|  | ||||
|         // Scan this location into another one | ||||
|         if (InvenTreeAPI().checkPermission("stock_location", "change")) { | ||||
|           tiles.add( | ||||
|             ListTile( | ||||
|               title: Text(L10().transferStockLocation), | ||||
|               subtitle: Text(L10().transferStockLocationDetail), | ||||
|               leading: FaIcon(FontAwesomeIcons.signInAlt, color: COLOR_CLICK), | ||||
|               trailing: Icon(Icons.qr_code, color: COLOR_CLICK), | ||||
|               onTap: () { | ||||
|                 var _loc = location; | ||||
|  | ||||
|                 if (_loc != null) { | ||||
|                   Navigator.push( | ||||
|                       context, | ||||
|                       MaterialPageRoute(builder: (context) => | ||||
|                           InvenTreeQRView( | ||||
|                               ScanParentLocationHandler(_loc))) | ||||
|                   ).then((value) { | ||||
|                     refresh(context); | ||||
|                   }); | ||||
|                 } | ||||
|               } | ||||
|             ) | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // Move location into another location | ||||
|     // TODO: Implement this! | ||||
|     /* | ||||
|     tiles.add( | ||||
|       ListTile( | ||||
|         title: Text("Move Stock Location"), | ||||
|         leading: FaIcon(FontAwesomeIcons.sitemap), | ||||
|         trailing: Icon(Icons.qr_code), | ||||
|       ) | ||||
|     ); | ||||
|      */ | ||||
|  | ||||
|     if (tiles.length <= 1) { | ||||
|       tiles.add( | ||||
|         ListTile( | ||||
|   | ||||
| @@ -381,7 +381,7 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> { | ||||
|       tiles.add( | ||||
|           ListTile( | ||||
|             title: Text("${part.keywords}"), | ||||
|             leading: FaIcon(FontAwesomeIcons.key), | ||||
|             leading: FaIcon(FontAwesomeIcons.tags), | ||||
|           ) | ||||
|       ); | ||||
|     } | ||||
|   | ||||
| @@ -210,6 +210,9 @@ class _PurchaseOrderDetailState extends RefreshableState<PurchaseOrderDetailWidg | ||||
|  | ||||
|   } | ||||
|  | ||||
|   /* | ||||
|    * Receive a specified PurchaseOrderLineItem into stock | ||||
|    */ | ||||
|   void receiveLine(BuildContext context, InvenTreePOLineItem lineItem) { | ||||
|  | ||||
|     Map<String, dynamic> fields = { | ||||
|   | ||||
| @@ -1,16 +1,20 @@ | ||||
| import "package:flutter/material.dart"; | ||||
| import "package:font_awesome_flutter/font_awesome_flutter.dart"; | ||||
| import "package:one_context/one_context.dart"; | ||||
| import "package:inventree/l10.dart"; | ||||
|  | ||||
| import "package:inventree/helpers.dart"; | ||||
| import "package:inventree/l10.dart"; | ||||
|  | ||||
| /* | ||||
|  * Display a configurable 'snackbar' at the bottom of the screen | ||||
|  */ | ||||
| void showSnackIcon(String text, {IconData? icon, Function()? onAction, bool? success, String? actionText}) { | ||||
|  | ||||
|   debug("showSnackIcon: '${text}'"); | ||||
|  | ||||
|   // Escape quickly if we do not have context | ||||
|   if (!OneContext.hasContext) { | ||||
|     // Debug message for unit testing | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -402,20 +402,23 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> { | ||||
|  | ||||
|   Future<void> _unassignBarcode(BuildContext context) async { | ||||
|  | ||||
|     final bool result = await item.update(values: {"uid": ""}); | ||||
|     final response = await item.update(values: {"uid": ""}); | ||||
|  | ||||
|     if (result) { | ||||
|       showSnackIcon( | ||||
|         L10().stockItemUpdateSuccess, | ||||
|         success: true | ||||
|       ); | ||||
|     } else { | ||||
|       showSnackIcon( | ||||
|         L10().stockItemUpdateFailure, | ||||
|         success: false, | ||||
|       ); | ||||
|     switch (response.statusCode) { | ||||
|       case 200: | ||||
|       case 201: | ||||
|         showSnackIcon( | ||||
|             L10().stockItemUpdateSuccess, | ||||
|             success: true | ||||
|         ); | ||||
|         break; | ||||
|       default: | ||||
|         showSnackIcon( | ||||
|           L10().stockItemUpdateFailure, | ||||
|           success: false, | ||||
|         ); | ||||
|         break; | ||||
|     } | ||||
|  | ||||
|     refresh(context); | ||||
|   } | ||||
|  | ||||
| @@ -779,6 +782,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> { | ||||
|     tiles.add( | ||||
|       ListTile( | ||||
|         title: Text(L10().transferStock), | ||||
|         subtitle: Text(L10().transferStockDetail), | ||||
|         leading: FaIcon(FontAwesomeIcons.exchangeAlt, color: COLOR_CLICK), | ||||
|         onTap: () { _transferStockDialog(context); }, | ||||
|       ) | ||||
| @@ -788,6 +792,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> { | ||||
|     tiles.add( | ||||
|       ListTile( | ||||
|         title: Text(L10().scanIntoLocation), | ||||
|         subtitle: Text(L10().scanIntoLocationDetail), | ||||
|         leading: FaIcon(FontAwesomeIcons.exchangeAlt, color: COLOR_CLICK), | ||||
|         trailing: Icon(Icons.qr_code_scanner), | ||||
|         onTap: () { | ||||
| @@ -806,6 +811,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> { | ||||
|       tiles.add( | ||||
|         ListTile( | ||||
|           title: Text(L10().barcodeAssign), | ||||
|           subtitle: Text(L10().barcodeAssignDetail), | ||||
|           leading: Icon(Icons.qr_code), | ||||
|           trailing: Icon(Icons.qr_code_scanner), | ||||
|           onTap: () { | ||||
| @@ -815,17 +821,23 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> { | ||||
|                 values: { | ||||
|                   "uid": hash, | ||||
|                 } | ||||
|               ).then((result) { | ||||
|                 if (result) { | ||||
|                   barcodeSuccessTone(); | ||||
|               ).then((response) { | ||||
|  | ||||
|                   showSnackIcon( | ||||
|                     L10().barcodeAssigned, | ||||
|                     success: true, | ||||
|                     icon: Icons.qr_code, | ||||
|                   ); | ||||
|                 switch (response.statusCode) { | ||||
|                   case 200: | ||||
|                   case 201: | ||||
|                     barcodeSuccessTone(); | ||||
|  | ||||
|                   refresh(context); | ||||
|                     showSnackIcon( | ||||
|                       L10().barcodeAssigned, | ||||
|                       success: true, | ||||
|                       icon: Icons.qr_code, | ||||
|                     ); | ||||
|  | ||||
|                     refresh(context); | ||||
|                     break; | ||||
|                   default: | ||||
|                     break; | ||||
|                 } | ||||
|               }); | ||||
|             }); | ||||
|   | ||||
| @@ -59,7 +59,7 @@ class _PaginatedStockItemListState extends PaginatedSearchState<PaginatedStockIt | ||||
|   Map<String, String> get orderingOptions => { | ||||
|     "part__name": L10().name, | ||||
|     "part__IPN": L10().internalPartNumber, | ||||
|     "quantity": L10().quantity, | ||||
|     "stock": L10().quantity, | ||||
|     "status": L10().status, | ||||
|     "batch": L10().batchCode, | ||||
|     "updated": L10().lastUpdated, | ||||
|   | ||||
| @@ -5,6 +5,7 @@ | ||||
| import "package:test/test.dart"; | ||||
|  | ||||
| import "package:inventree/api.dart"; | ||||
| import "package:inventree/helpers.dart"; | ||||
| import "package:inventree/user_profile.dart"; | ||||
|  | ||||
|  | ||||
| @@ -94,6 +95,9 @@ void main() { | ||||
|  | ||||
|         assert(!api.checkConnection()); | ||||
|  | ||||
|         debugContains("Token request failed: STATUS 401"); | ||||
|         debugContains("showSnackIcon: 'Not Connected'"); | ||||
|  | ||||
|       } else { | ||||
|         assert(false); | ||||
|       } | ||||
| @@ -137,6 +141,9 @@ void main() { | ||||
|       assert(api.checkPermission("stocklocation", "delete")); | ||||
|       assert(!api.checkPermission("part", "weirdpermission")); | ||||
|       assert(api.checkPermission("blah", "bloo")); | ||||
|  | ||||
|       debugContains("Received token from server"); | ||||
|       debugContains("showSnackIcon: 'Connected to Server'"); | ||||
|     }); | ||||
|  | ||||
|   }); | ||||
|   | ||||
							
								
								
									
										160
									
								
								test/barcode_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								test/barcode_test.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,160 @@ | ||||
| /* | ||||
|  * Unit testing for barcode scanning functionality. | ||||
|  * | ||||
|  * As the unit testing framework cannot really "scan" barcode data, | ||||
|  * we will mock the scanned data by passing raw "barcode" data to the scanning framework. | ||||
|  */ | ||||
|  | ||||
| import "package:flutter_test/flutter_test.dart"; | ||||
|  | ||||
| import "package:inventree/api.dart"; | ||||
| import "package:inventree/barcode.dart"; | ||||
| import "package:inventree/helpers.dart"; | ||||
| import "package:inventree/user_profile.dart"; | ||||
|  | ||||
| import "package:inventree/inventree/stock.dart"; | ||||
|  | ||||
| void main() { | ||||
|  | ||||
|   // Connect to the server | ||||
|   setUpAll(() async { | ||||
|     final prf = await UserProfileDBManager().getProfileByName("Test Profile"); | ||||
|  | ||||
|     if (prf != null) { | ||||
|       UserProfileDBManager().deleteProfile(prf); | ||||
|     } | ||||
|  | ||||
|     bool result = await UserProfileDBManager().addProfile( | ||||
|       UserProfile( | ||||
|         name: "Test Profile", | ||||
|         server: "http://localhost:12345", | ||||
|         username: "testuser", | ||||
|         password: "testpassword", | ||||
|         selected: true, | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     assert(result); | ||||
|  | ||||
|     assert(await UserProfileDBManager().selectProfileByName("Test Profile")); | ||||
|     assert(await InvenTreeAPI().connectToServer()); | ||||
|   }); | ||||
|  | ||||
|   setUp(() async { | ||||
|     // Clear the debug log | ||||
|     clearDebugMessage(); | ||||
|   }); | ||||
|  | ||||
|   group("Test BarcodeScanHandler:", () { | ||||
|     // Tests for scanning a "generic" barcode | ||||
|  | ||||
|     var handler = BarcodeScanHandler(); | ||||
|  | ||||
|     test("Empty Barcode", () async { | ||||
|       // Handle an 'empty' barcode | ||||
|       await handler.processBarcode(null, ""); | ||||
|  | ||||
|       debugContains("Scanned barcode data: ''"); | ||||
|       debugContains("showSnackIcon: 'Barcode scan error'"); | ||||
|  | ||||
|       assert(debugMessageCount() == 2); | ||||
|     }); | ||||
|  | ||||
|     test("Junk Data", () async { | ||||
|       // test scanning 'junk' data | ||||
|  | ||||
|       await handler.processBarcode(null, "abcdefg"); | ||||
|  | ||||
|       debugContains("Scanned barcode data: 'abcdefg'"); | ||||
|       debugContains("showSnackIcon: 'No match for barcode'"); | ||||
|     }); | ||||
|  | ||||
|     test("Invalid StockLocation", () async { | ||||
|       // Scan an invalid stock location | ||||
|       await handler.processBarcode(null, '{"stocklocation": 999999}'); | ||||
|  | ||||
|       debugContains("Scanned barcode data: '{\"stocklocation\": 999999}'"); | ||||
|       debugContains("showSnackIcon: 'No match for barcode'"); | ||||
|       assert(debugMessageCount() == 2); | ||||
|     }); | ||||
|  | ||||
|   }); | ||||
|  | ||||
|   group("Test StockItemScanIntoLocationHandler:", () { | ||||
|     // Tests for scanning a stock item into a location | ||||
|  | ||||
|     test("Scan Into Location", () async { | ||||
|  | ||||
|       final item = await InvenTreeStockItem().get(1) as InvenTreeStockItem?; | ||||
|  | ||||
|       assert(item != null); | ||||
|       assert(item!.pk == 1); | ||||
|  | ||||
|       var handler = StockItemScanIntoLocationHandler(item!); | ||||
|  | ||||
|       await handler.processBarcode(null, '{"stocklocation": 7}'); | ||||
|       // Check the location has been updated | ||||
|       await item.reload(); | ||||
|       assert(item.locationId == 7); | ||||
|  | ||||
|       debugContains("Scanned stock location 7"); | ||||
|  | ||||
|       // Scan into a new location | ||||
|       await handler.processBarcode(null, '{"stocklocation": 1}'); | ||||
|       await item.reload(); | ||||
|       assert(item.locationId == 1); | ||||
|  | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   group("Test StockLocationScanInItemsHandler:", () { | ||||
|     // Tests for scanning items into a stock location | ||||
|  | ||||
|     test("Scan In Items", () async { | ||||
|       final location = await InvenTreeStockLocation().get(1) as InvenTreeStockLocation?; | ||||
|  | ||||
|       assert(location != null); | ||||
|       assert(location!.pk == 1); | ||||
|  | ||||
|       var handler = StockLocationScanInItemsHandler(location!); | ||||
|  | ||||
|       // Scan multiple items into this location | ||||
|       for (int id in [1, 2, 11]) { | ||||
|         await handler.processBarcode(null, '{"stockitem": ${id}}'); | ||||
|  | ||||
|         var item = await InvenTreeStockItem().get(id) as InvenTreeStockItem?; | ||||
|  | ||||
|         assert(item != null); | ||||
|         assert(item!.pk == id); | ||||
|         assert(item!.locationId == 1); | ||||
|       } | ||||
|  | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   group("Test ScanParentLocationHandler:", () { | ||||
|     // Tests for scanning a location into a parent location | ||||
|  | ||||
|     test("Scan Parent", () async { | ||||
|       final location = await InvenTreeStockLocation().get(7) as InvenTreeStockLocation?; | ||||
|  | ||||
|       assert(location != null); | ||||
|       assert(location!.pk == 7); | ||||
|       assert(location!.parentId == 4); | ||||
|  | ||||
|       var handler = ScanParentLocationHandler(location!); | ||||
|  | ||||
|       // Scan into new parent location | ||||
|       await handler.processBarcode(null, '{"stocklocation": 1}'); | ||||
|       await location.reload(); | ||||
|       assert(location.parentId == 1); | ||||
|  | ||||
|       // Scan back into old parent location | ||||
|       await handler.processBarcode(null, '{"stocklocation": 4}'); | ||||
|       await location.reload(); | ||||
|       assert(location.parentId == 4); | ||||
|  | ||||
|       debugContains("showSnackIcon: 'Scanned into location'"); | ||||
|     }); | ||||
|   }); | ||||
| } | ||||
| @@ -2,11 +2,11 @@ | ||||
|  * Unit tests for accessing various model classes via the API | ||||
|  */ | ||||
|  | ||||
| import "package:inventree/inventree/model.dart"; | ||||
| import "package:test/test.dart"; | ||||
|  | ||||
| import "package:inventree/api.dart"; | ||||
| import "package:inventree/user_profile.dart"; | ||||
| import "package:inventree/inventree/model.dart"; | ||||
| import "package:inventree/inventree/part.dart"; | ||||
|  | ||||
|  | ||||
| @@ -112,32 +112,40 @@ void main() { | ||||
|       assert(result != null); | ||||
|       assert(result is InvenTreePart); | ||||
|  | ||||
|       APIResponse? response; | ||||
|  | ||||
|       if (result != null) { | ||||
|         InvenTreePart part = result as InvenTreePart; | ||||
|         assert(part.name == "M2x4 LPHS"); | ||||
|  | ||||
|         // Change the name to something else | ||||
|         assert(await part.update( | ||||
|  | ||||
|         response = await part.update( | ||||
|           values: { | ||||
|             "name": "Woogle", | ||||
|           } | ||||
|         )); | ||||
|         ); | ||||
|  | ||||
|         assert(response.isValid()); | ||||
|         assert(response.statusCode == 200); | ||||
|  | ||||
|         assert(await part.reload()); | ||||
|         assert(part.name == "Woogle"); | ||||
|  | ||||
|         // And change it back again | ||||
|         assert(await part.update( | ||||
|         response = await part.update( | ||||
|           values: { | ||||
|             "name": "M2x4 LPHS" | ||||
|           } | ||||
|         )); | ||||
|         ); | ||||
|  | ||||
|         assert(response.isValid()); | ||||
|         assert(response.statusCode == 200); | ||||
|  | ||||
|         assert(await part.reload()); | ||||
|         assert(part.name == "M2x4 LPHS"); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|   }); | ||||
|  | ||||
| } | ||||
		Reference in New Issue
	
	Block a user