diff --git a/assets/release_notes.md b/assets/release_notes.md index bf6e7837..1b6d67c0 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -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 --- diff --git a/lib/api.dart b/lib/api.dart index 63f29db6..3851659c 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -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"; diff --git a/lib/api_form.dart b/lib/api_form.dart index 23b8e753..c6da25e4 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -857,6 +857,7 @@ Future launchApiForm( Map serverFields = {}; if (url.isNotEmpty) { + var options = await InvenTreeAPI().options(url); // Invalid response from server diff --git a/lib/barcode.dart b/lib/barcode.dart index 29beebb7..c5825e51 100644 --- a/lib/barcode.dart +++ b/lib/barcode.dart @@ -43,14 +43,13 @@ Future 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 onBarcodeMatched(BuildContext context, Map data) async { + Future onBarcodeMatched(Map data) async { // Called when the server "matches" a barcode // Override this function } - Future onBarcodeUnknown(BuildContext context, Map data) async { + Future onBarcodeUnknown(Map data) async { // Called when the server does not know about a barcode // Override this function @@ -76,7 +75,7 @@ class BarcodeHandler { ); } - Future onBarcodeUnhandled(BuildContext context, Map data) async { + Future onBarcodeUnhandled(Map data) async { barcodeFailureTone(); @@ -86,12 +85,27 @@ class BarcodeHandler { _controller?.resumeCamera(); } - Future processBarcode(BuildContext context, QRViewController? _controller, String barcode, {String url = "barcode/"}) async { + /* + * Base function to capture and process barcode data. + */ + Future 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 onBarcodeUnknown(BuildContext context, Map data) async { + Future onBarcodeUnknown(Map data) async { barcodeFailureTone(); @@ -158,7 +179,7 @@ class BarcodeScanHandler extends BarcodeHandler { } @override - Future onBarcodeMatched(BuildContext context, Map data) async { + Future onBarcodeMatched(Map 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 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(); + + 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 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 onLocationScanned(int locationId) async { - @override - Future onBarcodeMatched(BuildContext context, Map 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 onBarcodeMatched(BuildContext context, Map data) async { + Future 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 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 onBarcodeMatched(BuildContext context, Map data) async { + Future onBarcodeMatched(Map data) async { barcodeFailureTone(); @@ -435,7 +564,7 @@ class UniqueBarcodeHandler extends BarcodeHandler { } @override - Future onBarcodeUnknown(BuildContext context, Map data) async { + Future onBarcodeUnknown(Map 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 { _controller?.pauseCamera(); if (barcode.code != null) { - _handler.processBarcode(context, _controller, barcode.code ?? ""); + _handler.processBarcode(_controller, barcode.code ?? ""); } }); } diff --git a/lib/helpers.dart b/lib/helpers.dart index 24dc6e84..95586c37 100644 --- a/lib/helpers.dart +++ b/lib/helpers.dart @@ -13,12 +13,37 @@ import "package:audioplayers/audioplayers.dart"; import "package:one_context/one_context.dart"; +List 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 playAudioFile(String path) async { + // Debug message for unit testing + debug("Playing audio file: '${path}'"); + if (!OneContext.hasContext) { return; } final player = AudioCache(); player.play(path); - } diff --git a/lib/inventree/model.dart b/lib/inventree/model.dart index 75d31a4d..e6d81334 100644 --- a/lib/inventree/model.dart +++ b/lib/inventree/model.dart @@ -107,6 +107,9 @@ class InvenTreeModel { } + /* + * Launch a modal form to edit the fields available to this model instance. + */ Future editForm(BuildContext context, String title, {Map fields=const {}, Function(dynamic)? onSuccess}) async { if (fields.isEmpty) { @@ -317,7 +320,7 @@ class InvenTreeModel { } // POST data to update the model - Future update({Map values = const {}}) async { + Future update({Map 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 diff --git a/lib/inventree/part.dart b/lib/inventree/part.dart index 8e58eaf2..aa71ca34 100644 --- a/lib/inventree/part.dart +++ b/lib/inventree/part.dart @@ -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 psplit = pathstring.split("/"); if (psplit.isNotEmpty) { diff --git a/lib/inventree/stock.dart b/lib/inventree/stock.dart index 0bc826fb..12f0f2dc 100644 --- a/lib/inventree/stock.dart +++ b/lib/inventree/stock.dart @@ -535,7 +535,7 @@ class InvenTreeStockItem extends InvenTreeModel { * - Remove * - Count */ - Future adjustStock(BuildContext context, String endpoint, double q, {String? notes, int? location}) async { + Future 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 countStock(BuildContext context, double q, {String? notes}) async { + Future 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 addStock(BuildContext context, double q, {String? notes}) async { + Future 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 removeStock(BuildContext context, double q, {String? notes}) async { + Future 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 transferStock(BuildContext context, int location, {double? quantity, String? notes}) async { + Future 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 psplit = pathstring.split("/"); if (psplit.isNotEmpty) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4c00ca11..638975ae 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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": {}, diff --git a/lib/user_profile.dart b/lib/user_profile.dart index 9eff1edf..7f6cafd6 100644 --- a/lib/user_profile.dart +++ b/lib/user_profile.dart @@ -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 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 */ diff --git a/lib/widget/category_display.dart b/lib/widget/category_display.dart index 2ce7e02d..78b2128f 100644 --- a/lib/widget/category_display.dart +++ b/lib/widget/category_display.dart @@ -131,7 +131,7 @@ class _CategoryDisplayState extends RefreshableState { children.add( ListTile( title: Text(L10().parentCategory), - subtitle: Text("${category?.parentpathstring}"), + subtitle: Text("${category?.parentPathString}"), leading: FaIcon( FontAwesomeIcons.levelUpAlt, color: COLOR_CLICK, diff --git a/lib/widget/dialogs.dart b/lib/widget/dialogs.dart index a81415c1..151958c2 100644 --- a/lib/widget/dialogs.dart +++ b/lib/widget/dialogs.dart @@ -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 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 confirmationDialog(String title, String text, {IconData icon = Font } -Future 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 showErrorDialog(String title, {String description = "", APIResponse? response, IconData icon = FontAwesomeIcons.exclamationCircle, Function? onDismissed}) async { - String _error = error ?? L10().error; + List 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) { + 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 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 ); } diff --git a/lib/widget/location_display.dart b/lib/widget/location_display.dart index c1647eeb..b8169946 100644 --- a/lib/widget/location_display.dart +++ b/lib/widget/location_display.dart @@ -40,27 +40,6 @@ class _LocationDisplayState extends RefreshableState { List actions = []; - /* - actions.add( - IconButton( - icon: FaIcon(FontAwesomeIcons.search), - onPressed: () { - - Map 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 { 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 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 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 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 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( diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index 8dc162fd..4302565f 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -381,7 +381,7 @@ class _PartDisplayState extends RefreshableState { tiles.add( ListTile( title: Text("${part.keywords}"), - leading: FaIcon(FontAwesomeIcons.key), + leading: FaIcon(FontAwesomeIcons.tags), ) ); } diff --git a/lib/widget/purchase_order_detail.dart b/lib/widget/purchase_order_detail.dart index 2b511446..92f71459 100644 --- a/lib/widget/purchase_order_detail.dart +++ b/lib/widget/purchase_order_detail.dart @@ -210,6 +210,9 @@ class _PurchaseOrderDetailState extends RefreshableState fields = { diff --git a/lib/widget/snacks.dart b/lib/widget/snacks.dart index f578debf..1b316f3d 100644 --- a/lib/widget/snacks.dart +++ b/lib/widget/snacks.dart @@ -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; } diff --git a/lib/widget/stock_detail.dart b/lib/widget/stock_detail.dart index 85121036..d982b0bc 100644 --- a/lib/widget/stock_detail.dart +++ b/lib/widget/stock_detail.dart @@ -402,20 +402,23 @@ class _StockItemDisplayState extends RefreshableState { Future _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 { 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 { 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 { 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 { 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; } }); }); diff --git a/lib/widget/stock_list.dart b/lib/widget/stock_list.dart index 19edcd54..e4237bdf 100644 --- a/lib/widget/stock_list.dart +++ b/lib/widget/stock_list.dart @@ -59,7 +59,7 @@ class _PaginatedStockItemListState extends PaginatedSearchState 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, diff --git a/test/api_test.dart b/test/api_test.dart index fdc3ad6a..c1a3b48d 100644 --- a/test/api_test.dart +++ b/test/api_test.dart @@ -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'"); }); }); diff --git a/test/barcode_test.dart b/test/barcode_test.dart new file mode 100644 index 00000000..657d1533 --- /dev/null +++ b/test/barcode_test.dart @@ -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'"); + }); + }); +} \ No newline at end of file diff --git a/test/models_test.dart b/test/models_test.dart index e3d88140..4725c5ac 100644 --- a/test/models_test.dart +++ b/test/models_test.dart @@ -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"); } }); - }); } \ No newline at end of file