2
0
mirror of https://github.com/inventree/inventree-app.git synced 2025-04-27 04:56:48 +00:00

Stock display (#379)

* Display stock quantity more prominently

* Cleanup search widget

* Update for stock_detail widget

* More tweaks

* Change bottom bar icon

* Display boolean parameters appropriately

* Adds ability to edit part parameters

* Bump icon size a bit

* Improvements to filter options

- Allow filtering by "option" type
- To start with, filter stock by status code

* Remove debug message

* Remove getTriState method

- No longer needed
- Remove associated unit tests

* Adjust filters based on server API version

* Muted colors
This commit is contained in:
Oliver 2023-06-24 11:34:42 +10:00 committed by GitHub
parent 8076887e39
commit e9eb84eace
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 278 additions and 167 deletions

1
.gitignore vendored
View File

@ -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

View File

@ -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

View File

@ -241,6 +241,7 @@
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (

View File

@ -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,

View File

@ -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<APIFormWidget> {
for (var field in widget.fields) {
if (field.readOnly) {
continue;
}
if (field.isSimple) {
// Simple top-level field data
data[field.name] = field.data["value"];

View File

@ -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);
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);

View File

@ -133,12 +133,29 @@ class InvenTreePartParameter extends InvenTreeModel {
@override
String get URL => "part/parameter/";
@override
List<String> get rolesRequired => ["part"];
@override
InvenTreeModel createFromJson(Map<String, dynamic> json) => InvenTreePartParameter.fromJson(json);
@override
Map<String, dynamic> formFields() {
return {};
Map<String, dynamic> 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);
}
/*

View File

@ -23,6 +23,26 @@ class InvenTreeStatusCode {
// Internal status code data loaded from server
Map<String, dynamic> data = {};
/*
* Construct a list of "choices" suitable for a form
*/
List<dynamic> get choices {
List<dynamic> _choices = [];
for (String key in data.keys) {
dynamic _entry = data[key];
if (_entry is Map<String, dynamic>) {
_choices.add({
"value": _entry["key"],
"display_name": _entry["label"]
});
}
}
return _choices;
}
// Load status code information from the server
Future<void> load({bool forceReload = false}) async {

View File

@ -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;
}
}

View File

@ -278,6 +278,9 @@
"editNotes": "Edit Notes",
"@editNotes": {},
"editParameter": "Edit Parameter",
"@editParameter": {},
"editPart": "Edit Part",
"@editPart": {
"description": "edit part"

View File

@ -126,7 +126,12 @@ class InvenTreeSettingsManager {
Future<dynamic> 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<bool?> 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<void> setValue(String key, dynamic value) async {
// Encode null values as strings
value ??= "null";
value ??= "__null__";
await store.record(key).put(await _db, value);
}

View File

@ -154,8 +154,8 @@ class CheckBoxField extends FormField<bool> {
onSaved: onSaved,
initialValue: initial,
builder: (FormFieldState<bool> state) {
return CheckboxListTile(
//dense: state.hasError,
title: label != null ? Text(label, style: labelStyle) : null,
value: state.value,
tristate: tristate,

View File

@ -38,44 +38,39 @@ abstract class PaginatedSearchState<T extends PaginatedSearchWidget> 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<String, Map<String, dynamic>> get filterOptions => {};
// Return the boolean value of a particular boolean filter
Future<bool?> getBooleanFilterValue(String key) async {
key = "${prefix}bool_${key}";
Future<dynamic> getFilterValue(String key) async {
key = "${prefix}filter_${key}";
Map<String, dynamic> 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<void> setBooleanFilterValue(String key, bool? value) async {
key = "${prefix}bool_${key}";
Future<void> setFilterValue(String key, dynamic value) async {
key = "${prefix}filter_${key}";
await InvenTreeSettingsManager().setValue(key, value);
}
// Construct the boolean filter options for this list
Future<Map<String, String>> constructBooleanFilters() async {
Future<Map<String, String>> constructFilters() async {
Map<String, String> 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<T extends PaginatedSearchWidget> extends Sta
}
};
// Add in boolean filter options
// Add in selected filter options
for (String key in filterOptions.keys) {
Map<String, dynamic> opts = filterOptions[key] ?? {};
@ -172,17 +167,18 @@ abstract class PaginatedSearchState<T extends PaginatedSearchWidget> extends Sta
String label = (opts["label"] ?? key) as String;
String? help_text = opts["help_text"] as String?;
List<dynamic> choices = (opts["choices"] ?? []) as List<dynamic>;
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<String, dynamic> filter = {
"type": "boolean",
"display_name": label,
"label": label,
@ -190,6 +186,16 @@ abstract class PaginatedSearchState<T extends PaginatedSearchWidget> 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<T extends PaginatedSearchWidget> 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<T extends PaginatedSearchWidget> extends Sta
params["ordering"] = o;
}
Map<String, String> f = await constructBooleanFilters();
Map<String, String> f = await constructFilters();
if (f.isNotEmpty) {
params.addAll(f);
@ -348,6 +345,10 @@ abstract class PaginatedSearchState<T extends PaginatedSearchWidget> 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<T extends PaginatedSearchWidget> 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();

View File

@ -228,20 +228,6 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
});
}
/*
* Toggle the "star" status of this paricular part
*/
Future <void> _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<PartDetailWidget> {
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),

View File

@ -133,7 +133,13 @@ class _PaginatedPartListState extends PaginatedSearchState<PaginatedPartList> {
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)));

View File

@ -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<PaginatedParameterLi
@override
Map<String, String> get orderingOptions => {
// TODO
};
@override
@ -91,6 +92,22 @@ class _PaginatedParameterState extends PaginatedSearchState<PaginatedParameterLi
return page;
}
Future<void> 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<PaginatedParameterLi
return ListTile(
title: Text(parameter.name),
subtitle: Text(parameter.description),
trailing: Text(parameter.valueString),
trailing: parameter.is_checkbox
? Switch(
value: parameter.as_bool,
onChanged: (bool value) {
if (parameter.canEdit) {
showLoadingOverlay(context);
parameter.update(
values: {
"data": value.toString()
}
).then((value) async{
hideLoadingOverlay();
updateSearchTerm();
});
}
},
) : Text(parameter.valueString),
onTap: parameter.is_checkbox ? null : () async {
if (parameter.canEdit) {
editParameter(parameter);
}
},
);
}
}

View File

@ -71,7 +71,7 @@ mixin BaseWidgetProperties {
*/
BottomAppBar? buildBottomAppBar(BuildContext context, GlobalKey<ScaffoldState> key) {
const double iconSize = 32;
const double iconSize = 40;
List<Widget> 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()) {

View File

@ -577,8 +577,9 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
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<StockDetailWidget> {
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<StockDetailWidget> {
);
}
// 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<StockDetailWidget> {
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(

View File

@ -79,24 +79,37 @@ class _PaginatedStockItemListState extends PaginatedSearchState<PaginatedStockIt
};
@override
Map<String, Map<String, dynamic>> 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<String, Map<String, dynamic>> get filterOptions {
Map<String, Map<String, dynamic>> 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<InvenTreePageResponse?> requestPage(int limit, int offset, Map<String, String> params) async {
@ -125,6 +138,7 @@ class _PaginatedStockItemListState extends PaginatedSearchState<PaginatedStockIt
trailing: Text("${item.displayQuantity}",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: InvenTreeAPI().StockStatus.color(item.status),
),
),

View File

@ -47,25 +47,5 @@ void main() {
assert(await InvenTreeSettingsManager().getBool("chicken", true) == false);
});
test("Tri State", () async {
// Tests for tristate values
await InvenTreeSettingsManager().removeValue("dog");
// Use default values when a setting does not exist
for (bool? value in [true, false, null]) {
assert(await InvenTreeSettingsManager().getTriState("dog", value) == value);
}
// Explicitly set to a value
for (bool? value in [true, false, null]) {
await InvenTreeSettingsManager().setValue("dog", value);
assert(await InvenTreeSettingsManager().getTriState("dog", true) == value);
assert(await InvenTreeSettingsManager().getTriState("dog", false) == value);
assert(await InvenTreeSettingsManager().getTriState("dog", null) == value);
}
});
});
}