diff --git a/.gitignore b/.gitignore index 07101c9b..dd7fc904 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ coverage/* # This file is auto-generated as part of the CI process test/coverage_helper_test.dart +InvenTreeSettings.db # Sentry API key lib/dsn.dart diff --git a/assets/release_notes.md b/assets/release_notes.md index 15bc1545..edaf6f60 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -1,6 +1,8 @@ ### - --- +- Edit part parameters from within the app +- Increase visibility of stock quantity in widgets - Improved filters for stock list ### 0.12.2 - June 2023 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d46f76c1..69e5c04c 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -241,6 +241,7 @@ files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( diff --git a/lib/api.dart b/lib/api.dart index 7a707dfe..26cd5b90 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -1332,7 +1332,20 @@ class InvenTreeAPI { static String get staticThumb => "/static/img/blank_image.thumbnail.png"; - CachedNetworkImage getThumbnail(String imageUrl, {double size = 40}) => getImage(imageUrl, width: size, height: size); + CachedNetworkImage? getThumbnail(String imageUrl, {double size = 40, bool hideIfNull = false}) { + + if (hideIfNull) { + if (imageUrl.isEmpty) { + return null; + } + } + + return getImage( + imageUrl, + width: size, + height: size + ); + } /* * Load image from the InvenTree server, diff --git a/lib/api_form.dart b/lib/api_form.dart index 80e20f67..19f539de 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -275,6 +275,7 @@ class APIFormField { // Construct a widget for this input Widget constructField(BuildContext context) { + switch (type) { case "string": case "url": @@ -696,6 +697,14 @@ class APIFormField { // Construct a string input element Widget _constructString() { + if (readOnly) { + return ListTile( + title: Text(label), + subtitle: Text(helpText), + trailing: Text(value.toString()), + ); + } + return TextFormField( decoration: InputDecoration( labelText: required ? label + "*" : label, @@ -724,12 +733,21 @@ class APIFormField { // Construct a boolean input element Widget _constructBoolean() { + bool? initial_value; + + if (value is bool || value == null) { + initial_value = value as bool?; + } else { + String vs = value.toString().toLowerCase(); + initial_value = ["1", "true", "yes"].contains(vs); + } + return CheckBoxField( label: label, labelStyle: _labelStyle(), helperText: helpText, helperStyle: _helperStyle(), - initial: value as bool?, + initial: initial_value, tristate: (getParameter("tristate") ?? false) as bool, onSaved: (val) { data["value"] = val; @@ -1262,6 +1280,10 @@ class _APIFormWidgetState extends State { for (var field in widget.fields) { + if (field.readOnly) { + continue; + } + if (field.isSimple) { // Simple top-level field data data[field.name] = field.data["value"]; diff --git a/lib/app_colors.dart b/lib/app_colors.dart index a5556a06..091718b4 100644 --- a/lib/app_colors.dart +++ b/lib/app_colors.dart @@ -16,6 +16,6 @@ Color get COLOR_ACTION { } const Color COLOR_WARNING = Color.fromRGBO(250, 150, 50, 1); -const Color COLOR_DANGER = Color.fromRGBO(250, 50, 50, 1); -const Color COLOR_SUCCESS = Color.fromRGBO(50, 250, 50, 1); -const Color COLOR_PROGRESS = Color.fromRGBO(50, 50, 250, 1); \ No newline at end of file +const Color COLOR_DANGER = Color.fromRGBO(200, 50, 75, 1); +const Color COLOR_SUCCESS = Color.fromRGBO(100, 200, 75, 1); +const Color COLOR_PROGRESS = Color.fromRGBO(50, 100, 200, 1); \ No newline at end of file diff --git a/lib/inventree/part.dart b/lib/inventree/part.dart index b476937a..e335a119 100644 --- a/lib/inventree/part.dart +++ b/lib/inventree/part.dart @@ -133,12 +133,29 @@ class InvenTreePartParameter extends InvenTreeModel { @override String get URL => "part/parameter/"; + @override + List get rolesRequired => ["part"]; + @override InvenTreeModel createFromJson(Map json) => InvenTreePartParameter.fromJson(json); @override Map formFields() { - return {}; + + Map fields = { + "header": { + "type": "string", + "read_only": true, + "label": name, + "help_text": description, + "value": "", + }, + "data": { + "type": "string", + } + }; + + return fields; } @override @@ -160,7 +177,11 @@ class InvenTreePartParameter extends InvenTreeModel { return v; } + bool get as_bool => value.toLowerCase() == "true"; + String get units => getString("units", subKey: "template_detail"); + + bool get is_checkbox => getBool("checkbox", subKey: "template_detail", backup: false); } /* diff --git a/lib/inventree/status_codes.dart b/lib/inventree/status_codes.dart index 1a563a27..2d9b3c32 100644 --- a/lib/inventree/status_codes.dart +++ b/lib/inventree/status_codes.dart @@ -23,6 +23,26 @@ class InvenTreeStatusCode { // Internal status code data loaded from server Map data = {}; + /* + * Construct a list of "choices" suitable for a form + */ + List get choices { + List _choices = []; + + for (String key in data.keys) { + dynamic _entry = data[key]; + + if (_entry is Map) { + _choices.add({ + "value": _entry["key"], + "display_name": _entry["label"] + }); + } + } + + return _choices; + } + // Load status code information from the server Future load({bool forceReload = false}) async { diff --git a/lib/inventree/stock.dart b/lib/inventree/stock.dart index 7d06fa52..f6cca996 100644 --- a/lib/inventree/stock.dart +++ b/lib/inventree/stock.dart @@ -399,7 +399,7 @@ class InvenTreeStockItem extends InvenTreeModel { double get quantity => getDouble("quantity"); - String quantityString({bool includeUnits = false}){ + String quantityString({bool includeUnits = true}){ String q = ""; @@ -467,7 +467,13 @@ class InvenTreeStockItem extends InvenTreeModel { if (serialNumber.isNotEmpty) { return "SN: $serialNumber"; } else { - return simpleNumberString(quantity); + String q = simpleNumberString(quantity); + + if (units.isNotEmpty) { + q += " ${units}"; + } + + return q; } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bdda8dd8..16dae594 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -278,6 +278,9 @@ "editNotes": "Edit Notes", "@editNotes": {}, + "editParameter": "Edit Parameter", + "@editParameter": {}, + "editPart": "Edit Part", "@editPart": { "description": "edit part" diff --git a/lib/preferences.dart b/lib/preferences.dart index 694ad7ca..a7e8a30a 100644 --- a/lib/preferences.dart +++ b/lib/preferences.dart @@ -126,7 +126,12 @@ class InvenTreeSettingsManager { Future getValue(String key, dynamic backup) async { - final value = await store.record(key).get(await _db); + dynamic value = await store.record(key).get(await _db); + + // Retrieve value + if (value == "__null__") { + value = null; + } if (value == null) { return backup; @@ -148,32 +153,11 @@ class InvenTreeSettingsManager { } } - // Load a tristate (true / false / null) setting - Future getTriState(String key, dynamic backup) async { - final dynamic value = await getValue(key, backup); - - if (value == null) { - return null; - } else if (value is bool) { - return value; - } else { - String s = value.toString().toLowerCase(); - - if (s.contains("t")) { - return true; - } else if (s.contains("f")) { - return false; - } else { - return null; - } - } - } - // Store a key:value pair in the database Future setValue(String key, dynamic value) async { // Encode null values as strings - value ??= "null"; + value ??= "__null__"; await store.record(key).put(await _db, value); } diff --git a/lib/widget/fields.dart b/lib/widget/fields.dart index 16f45e65..bff9cf23 100644 --- a/lib/widget/fields.dart +++ b/lib/widget/fields.dart @@ -154,8 +154,8 @@ class CheckBoxField extends FormField { onSaved: onSaved, initialValue: initial, builder: (FormFieldState state) { + return CheckboxListTile( - //dense: state.hasError, title: label != null ? Text(label, style: labelStyle) : null, value: state.value, tristate: tristate, diff --git a/lib/widget/paginator.dart b/lib/widget/paginator.dart index e9cc4cf4..70ef6cb8 100644 --- a/lib/widget/paginator.dart +++ b/lib/widget/paginator.dart @@ -38,44 +38,39 @@ abstract class PaginatedSearchState extends Sta // Override in implementing class String get prefix => "prefix_"; - // Return a map of boolean filtering options available for this list // Should be overridden by an implementing subclass Map> get filterOptions => {}; // Return the boolean value of a particular boolean filter - Future getBooleanFilterValue(String key) async { - key = "${prefix}bool_${key}"; + Future getFilterValue(String key) async { + key = "${prefix}filter_${key}"; Map opts = filterOptions[key] ?? {}; + dynamic backup = opts["default"]; + final result = await InvenTreeSettingsManager().getValue(key, backup); - bool? backup; - dynamic v = opts["default"]; - - if (v is bool) { - backup = v; - } - - final result = await InvenTreeSettingsManager().getTriState(key, backup); return result; } // Set the boolean value of a particular boolean filter - Future setBooleanFilterValue(String key, bool? value) async { - key = "${prefix}bool_${key}"; + Future setFilterValue(String key, dynamic value) async { + key = "${prefix}filter_${key}"; await InvenTreeSettingsManager().setValue(key, value); } // Construct the boolean filter options for this list - Future> constructBooleanFilters() async { + Future> constructFilters() async { Map f = {}; for (String k in filterOptions.keys) { - bool? value = await getBooleanFilterValue(k); + dynamic value = await getFilterValue(k); - if (value is bool) { - f[k] = value ? "true" : "false"; + // Skip null values + if (value == null) { + continue; } + f[k] = value.toString(); } return f; @@ -164,7 +159,7 @@ abstract class PaginatedSearchState extends Sta } }; - // Add in boolean filter options + // Add in selected filter options for (String key in filterOptions.keys) { Map opts = filterOptions[key] ?? {}; @@ -172,17 +167,18 @@ abstract class PaginatedSearchState extends Sta String label = (opts["label"] ?? key) as String; String? help_text = opts["help_text"] as String?; + List choices = (opts["choices"] ?? []) as List; + bool tristate = (opts["tristate"] ?? true) as bool; - bool? v = await getBooleanFilterValue(key); + dynamic v = await getFilterValue(key); // Prevent null value if not tristate if (!tristate && v == null) { v = false; } - // Add in the particular field - fields[key] = { + Map filter = { "type": "boolean", "display_name": label, "label": label, @@ -190,6 +186,16 @@ abstract class PaginatedSearchState extends Sta "value": v, "tristate": (opts["tristate"] ?? true) as bool, }; + + if (choices.isNotEmpty) { + // Configure as a choice input + filter["type"] = "choice"; + filter["choices"] = choices; + + filter.remove("tristate"); + } + + fields[key] = filter; } // Launch an interactive form for the user to select options @@ -211,16 +217,7 @@ abstract class PaginatedSearchState extends Sta // Save boolean fields for (String key in filterOptions.keys) { - - bool? v; - - dynamic value = data[key]; - - if (value is bool) { - v = value; - } - - await setBooleanFilterValue(key, v); + await setFilterValue(key, data[key]); } // Refresh data from the server @@ -293,7 +290,7 @@ abstract class PaginatedSearchState extends Sta params["ordering"] = o; } - Map f = await constructBooleanFilters(); + Map f = await constructFilters(); if (f.isNotEmpty) { params.addAll(f); @@ -348,6 +345,10 @@ abstract class PaginatedSearchState extends Sta void updateSearchTerm() { searchTerm = searchController.text; _pagingController.refresh(); + + if (mounted) { + setState(() {}); + } } // Function to construct a single paginated item @@ -409,19 +410,19 @@ abstract class PaginatedSearchState extends Sta */ Widget buildSearchInput(BuildContext context) { return ListTile( - trailing: orderingOptions.isEmpty ? null : GestureDetector( - child: FaIcon(FontAwesomeIcons.sort, color: COLOR_ACTION), + leading: orderingOptions.isEmpty ? null : GestureDetector( + child: Icon(Icons.filter_list, color: COLOR_ACTION, size: 32), onTap: () async { _saveOrderingOptions(context); }, ), - leading: GestureDetector( + trailing: GestureDetector( child: FaIcon( searchController.text.isEmpty ? FontAwesomeIcons.magnifyingGlass : FontAwesomeIcons.deleteLeft, - color: searchController.text.isNotEmpty ? COLOR_DANGER : null, + color: searchController.text.isNotEmpty ? COLOR_DANGER : COLOR_ACTION, ), onTap: () { - if (searchController.text.isEmpty) { + if (searchController.text.isNotEmpty) { searchController.clear(); } updateSearchTerm(); diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index 1932780f..9376837f 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -228,20 +228,6 @@ class _PartDisplayState extends RefreshableState { }); } - - /* - * Toggle the "star" status of this paricular part - */ - Future _toggleStar(BuildContext context) async { - - if (InvenTreePart().canView) { - showLoadingOverlay(context); - await part.update(values: {"starred": "${!part.starred}"}); - hideLoadingOverlay(); - refresh(context); - } - } - void _editPartDialog(BuildContext context) { part.editForm( @@ -259,13 +245,11 @@ class _PartDisplayState extends RefreshableState { child: ListTile( title: Text("${part.fullname}"), subtitle: Text("${part.description}"), - trailing: IconButton( - icon: FaIcon(part.starred ? FontAwesomeIcons.solidStar : FontAwesomeIcons.star, - color: part.starred ? Colors.yellowAccent : null, - ), - onPressed: () { - _toggleStar(context); - }, + trailing: Text( + part.stockString(), + style: TextStyle( + fontSize: 20, + ) ), leading: GestureDetector( child: api.getImage(part.thumbnail), diff --git a/lib/widget/part_list.dart b/lib/widget/part_list.dart index 5235e54f..ba7c8391 100644 --- a/lib/widget/part_list.dart +++ b/lib/widget/part_list.dart @@ -133,7 +133,13 @@ class _PaginatedPartListState extends PaginatedSearchState { return ListTile( title: Text(part.fullname), subtitle: Text(part.description), - trailing: Text(part.stockString()), + trailing: Text( + part.stockString(), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold + ) + ), leading: InvenTreeAPI().getThumbnail(part.thumbnail), onTap: () { Navigator.push(context, MaterialPageRoute(builder: (context) => PartDetailWidget(part))); diff --git a/lib/widget/part_parameter_widget.dart b/lib/widget/part_parameter_widget.dart index 1adc5235..d889be81 100644 --- a/lib/widget/part_parameter_widget.dart +++ b/lib/widget/part_parameter_widget.dart @@ -4,6 +4,7 @@ import "package:inventree/inventree/model.dart"; import "package:inventree/l10.dart"; import "package:inventree/inventree/part.dart"; import "package:inventree/widget/paginator.dart"; +import "package:inventree/widget/progress.dart"; import "package:inventree/widget/refreshable_state.dart"; /* @@ -75,7 +76,7 @@ class _PaginatedParameterState extends PaginatedSearchState get orderingOptions => { - // TODO + }; @override @@ -91,6 +92,22 @@ class _PaginatedParameterState extends PaginatedSearchState editParameter(InvenTreePartParameter parameter) async { + + // Checkbox values are handled separately + if (parameter.is_checkbox) { + return; + } else { + parameter.editForm( + context, + L10().editParameter, + onSuccess: (data) async { + updateSearchTerm(); + } + ); + } + } + @override Widget buildItem(BuildContext context, InvenTreeModel model) { @@ -99,7 +116,28 @@ class _PaginatedParameterState extends PaginatedSearchState key) { - const double iconSize = 32; + const double iconSize = 40; List icons = [ IconButton( @@ -98,7 +98,7 @@ mixin BaseWidgetProperties { }, ), IconButton( - icon: Icon(Icons.qr_code_scanner, color: COLOR_ACTION), + icon: Icon(Icons.barcode_reader, color: COLOR_ACTION), iconSize: iconSize, onPressed: () { if (InvenTreeAPI().checkConnection()) { diff --git a/lib/widget/stock_detail.dart b/lib/widget/stock_detail.dart index 4deeaba4..c2830775 100644 --- a/lib/widget/stock_detail.dart +++ b/lib/widget/stock_detail.dart @@ -577,8 +577,9 @@ class _StockItemDisplayState extends RefreshableState { subtitle: Text("${widget.item.partDescription}"), leading: InvenTreeAPI().getThumbnail(widget.item.partImage), trailing: Text( - api.StockStatus.label(widget.item.status), + widget.item.quantityString(), style: TextStyle( + fontSize: 20, color: api.StockStatus.color(widget.item.status), ) ), @@ -615,6 +616,41 @@ class _StockItemDisplayState extends RefreshableState { return tiles; } + // Location information + if ((widget.item.locationId > 0) && (widget.item.locationName.isNotEmpty)) { + tiles.add( + ListTile( + title: Text(L10().stockLocation), + subtitle: Text("${widget.item.locationPathString}"), + leading: FaIcon( + FontAwesomeIcons.locationDot, + color: COLOR_ACTION, + ), + onTap: () async { + if (widget.item.locationId > 0) { + + showLoadingOverlay(context); + var loc = await InvenTreeStockLocation().get(widget.item.locationId); + hideLoadingOverlay(); + + if (loc is InvenTreeStockLocation) { + Navigator.push(context, MaterialPageRoute( + builder: (context) => LocationDisplayWidget(loc))); + } + } + }, + ), + ); + } else { + tiles.add( + ListTile( + title: Text(L10().stockLocation), + leading: FaIcon(FontAwesomeIcons.locationDot), + subtitle: Text(L10().locationNotSet), + ) + ); + } + // Quantity information if (widget.item.isSerialized()) { tiles.add( @@ -634,40 +670,19 @@ class _StockItemDisplayState extends RefreshableState { ); } - // Location information - if ((widget.item.locationId > 0) && (widget.item.locationName.isNotEmpty)) { - tiles.add( - ListTile( - title: Text(L10().stockLocation), - subtitle: Text("${widget.item.locationPathString}"), - leading: FaIcon( - FontAwesomeIcons.locationDot, - color: COLOR_ACTION, - ), - onTap: () async { - if (widget.item.locationId > 0) { - - showLoadingOverlay(context); - var loc = await InvenTreeStockLocation().get(widget.item.locationId); - hideLoadingOverlay(); - - if (loc is InvenTreeStockLocation) { - Navigator.push(context, MaterialPageRoute( - builder: (context) => LocationDisplayWidget(loc))); - } - } - }, - ), - ); - } else { - tiles.add( - ListTile( - title: Text(L10().stockLocation), - leading: FaIcon(FontAwesomeIcons.locationDot), - subtitle: Text(L10().locationNotSet), + // Stock item status information + tiles.add( + ListTile( + title: Text(L10().status), + leading: FaIcon(FontAwesomeIcons.circleInfo), + trailing: Text( + api.StockStatus.label(widget.item.status), + style: TextStyle( + color: api.StockStatus.color(widget.item.status), ) - ); - } + ) + ) + ); // Supplier part information (if available) if (widget.item.supplierPartId > 0) { @@ -676,7 +691,7 @@ class _StockItemDisplayState extends RefreshableState { title: Text(L10().supplierPart), subtitle: Text(widget.item.supplierSKU), leading: FaIcon(FontAwesomeIcons.building, color: COLOR_ACTION), - trailing: InvenTreeAPI().getThumbnail(widget.item.supplierImage), + trailing: InvenTreeAPI().getThumbnail(widget.item.supplierImage, hideIfNull: true), onTap: () async { showLoadingOverlay(context); var sp = await InvenTreeSupplierPart().get( diff --git a/lib/widget/stock_list.dart b/lib/widget/stock_list.dart index fee6d112..dd657a71 100644 --- a/lib/widget/stock_list.dart +++ b/lib/widget/stock_list.dart @@ -79,24 +79,37 @@ class _PaginatedStockItemListState extends PaginatedSearchState> get filterOptions => { - "in_stock": { - "default": true, - "label": L10().filterInStock, - "help_text": L10().filterInStockDetail, - "tristate": true, - }, - "cascade": { - "default": false, - "label": L10().includeSublocations, - "help_text": L10().includeSublocationsDetail, - "tristate": false, - }, - "serialized": { - "label": L10().filterSerialized, - "help_text": L10().filterSerializedDetail, + Map> get filterOptions { + Map> filters = { + "in_stock": { + "default": true, + "label": L10().filterInStock, + "help_text": L10().filterInStockDetail, + "tristate": true, + }, + "cascade": { + "default": false, + "label": L10().includeSublocations, + "help_text": L10().includeSublocationsDetail, + "tristate": false, + }, + "serialized": { + "label": L10().filterSerialized, + "help_text": L10().filterSerializedDetail, + }, + "status": { + "label": L10().status, + "help_text": L10().statusCode, + "choices": InvenTreeAPI().StockStatus.choices, + } + }; + + if (!InvenTreeAPI().supportsStatusLabelEndpoints) { + filters.remove("status"); } - }; + + return filters; + } @override Future requestPage(int limit, int offset, Map params) async { @@ -125,6 +138,7 @@ class _PaginatedStockItemListState extends PaginatedSearchState