mirror of
https://github.com/inventree/inventree-app.git
synced 2025-04-27 21:16:48 +00:00
* Handle error on unexpected barcode response * Add ManufacturerPart detail view * Support barcode scanning for manufacturer part * Refactoring for null checks * Ignore selected errors in sentry * Fix API implementation for ManufacturerPart * Update release notes * More error handling * Decode quantity betterer * Refactoring * Add option to confirm checkin details * Improve response handlign * Cleanup * Remove unused imports * Fix async function * Fix for assigning custom barcode * Handle barcode scan result for company * Fix * Adjust scan priority * Refactoring MODEL_TYPE - Use instead of duplicated const strings * @override fix
1547 lines
39 KiB
Dart
1547 lines
39 KiB
Dart
|
|
import "dart:io";
|
|
|
|
import "package:intl/intl.dart";
|
|
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
|
|
import "package:dropdown_search/dropdown_search.dart";
|
|
import "package:datetime_picker_formfield/datetime_picker_formfield.dart";
|
|
import "package:flutter/material.dart";
|
|
|
|
import "package:inventree/api.dart";
|
|
import "package:inventree/app_colors.dart";
|
|
import "package:inventree/barcode/barcode.dart";
|
|
import "package:inventree/helpers.dart";
|
|
import "package:inventree/l10.dart";
|
|
|
|
import "package:inventree/inventree/company.dart";
|
|
import "package:inventree/inventree/part.dart";
|
|
import "package:inventree/inventree/project_code.dart";
|
|
import "package:inventree/inventree/purchase_order.dart";
|
|
import "package:inventree/inventree/sales_order.dart";
|
|
import "package:inventree/inventree/stock.dart";
|
|
|
|
import "package:inventree/inventree/sentry.dart";
|
|
|
|
import "package:inventree/widget/dialogs.dart";
|
|
import "package:inventree/widget/fields.dart";
|
|
import "package:inventree/widget/progress.dart";
|
|
import "package:inventree/widget/snacks.dart";
|
|
|
|
|
|
/*
|
|
* Class that represents a single "form field",
|
|
* defined by the InvenTree API
|
|
*/
|
|
class APIFormField {
|
|
|
|
// Constructor
|
|
APIFormField(this.name, this.data);
|
|
|
|
// File to be uploaded for this filed
|
|
File? attachedfile;
|
|
|
|
// Name of this field
|
|
final String name;
|
|
|
|
// JSON data which defines the field
|
|
final Map<String, dynamic> data;
|
|
|
|
// JSON field definition provided by the server
|
|
Map<String, dynamic> definition = {};
|
|
|
|
dynamic initial_data;
|
|
|
|
// Return the "lookup path" for this field, within the server data
|
|
String get lookupPath {
|
|
|
|
// Simple top-level case
|
|
if (parent.isEmpty && !nested) {
|
|
return name;
|
|
}
|
|
|
|
List<String> path = [];
|
|
|
|
if (parent.isNotEmpty) {
|
|
path.add(parent);
|
|
path.add("child");
|
|
}
|
|
|
|
if (nested) {
|
|
path.add("children");
|
|
path.add(name);
|
|
}
|
|
|
|
return path.join(".");
|
|
}
|
|
|
|
/*
|
|
* Extract a field parameter from the provided field definition.
|
|
*
|
|
* - First the user-provided data is checked
|
|
* - Second, the server-provided definition is checked
|
|
*
|
|
* - Finally, return null
|
|
*/
|
|
dynamic getParameter(String key) {
|
|
if (data.containsKey(key)) {
|
|
return data[key];
|
|
} else if (definition.containsKey(key)) {
|
|
return definition[key];
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Get the "api_url" associated with a related field
|
|
String get api_url => (getParameter("api_url") ?? "") as String;
|
|
|
|
// Get the "model" associated with a related field
|
|
String get model => (getParameter("model") ?? "") as String;
|
|
|
|
// Is this field hidden?
|
|
bool get hidden => (getParameter("hidden") ?? false) as bool;
|
|
|
|
// Is this field nested? (Nested means part of an array)
|
|
// Note: This parameter is only defined locally
|
|
bool get nested => (data["nested"] ?? false) as bool;
|
|
|
|
// What is the "parent" field of this field?
|
|
// Note: This parameter is only defined locally
|
|
String get parent => (data["parent"] ?? "") as String;
|
|
|
|
bool get isSimple => !nested && parent.isEmpty;
|
|
|
|
// Is this field read only?
|
|
bool get readOnly => (getParameter("read_only") ?? false) as bool;
|
|
|
|
bool get multiline => (getParameter("multiline") ?? false) as bool;
|
|
|
|
// Get the "value" as a string (look for "default" if not available)
|
|
dynamic get value => data["value"] ?? data["instance_value"] ?? defaultValue;
|
|
|
|
// Render value to string (for form submission)
|
|
String renderValueToString() {
|
|
if (data["value"] == null) {
|
|
return "";
|
|
} else {
|
|
return data["value"].toString();
|
|
}
|
|
}
|
|
|
|
// Get the "default" as a string
|
|
dynamic get defaultValue => getParameter("default");
|
|
|
|
// Construct a set of "filters" for this field (e.g. related field)
|
|
Map<String, String> get filters {
|
|
|
|
Map<String, String> _filters = {};
|
|
|
|
// Start with the field "definition" (provided by the server)
|
|
if (definition.containsKey("filters")) {
|
|
|
|
try {
|
|
var fDef = definition["filters"] as Map<String, dynamic>;
|
|
|
|
fDef.forEach((String key, dynamic value) {
|
|
_filters[key] = value.toString();
|
|
});
|
|
|
|
} catch (error) {
|
|
// pass
|
|
}
|
|
}
|
|
|
|
// Next, look at any "instance_filters" provided by the server
|
|
if (definition.containsKey("instance_filters")) {
|
|
|
|
try {
|
|
var fIns = definition["instance_filters"] as Map<String, dynamic>;
|
|
|
|
fIns.forEach((String key, dynamic value) {
|
|
_filters[key] = value.toString();
|
|
});
|
|
} catch (error) {
|
|
// pass
|
|
}
|
|
|
|
}
|
|
|
|
// Finally, augment or override with any filters provided by the calling function
|
|
if (data.containsKey("filters")) {
|
|
try {
|
|
var fDat = data["filters"] as Map<String, dynamic>;
|
|
|
|
fDat.forEach((String key, dynamic value) {
|
|
_filters[key] = value.toString();
|
|
});
|
|
} catch (error) {
|
|
// pass
|
|
}
|
|
}
|
|
|
|
return _filters;
|
|
|
|
}
|
|
|
|
bool hasErrors() => errorMessages().isNotEmpty;
|
|
|
|
// Extract error messages from the server response
|
|
void extractErrorMessages(APIResponse response) {
|
|
|
|
dynamic errors;
|
|
|
|
if (isSimple) {
|
|
// Simple fields are easily handled
|
|
errors = response.data[name];
|
|
} else {
|
|
if (parent.isNotEmpty) {
|
|
dynamic parentElement = response.data[parent];
|
|
|
|
// Extract from list
|
|
if (parentElement is List) {
|
|
parentElement = parentElement[0];
|
|
}
|
|
|
|
if (parentElement is Map) {
|
|
errors = parentElement[name];
|
|
}
|
|
}
|
|
}
|
|
|
|
data["errors"] = errors;
|
|
}
|
|
|
|
// Return the error message associated with this field
|
|
List<String> errorMessages() {
|
|
|
|
dynamic errors = data["errors"] ?? [];
|
|
|
|
// Handle the case where a single error message is returned
|
|
if (errors is String) {
|
|
errors = [errors];
|
|
}
|
|
|
|
errors = errors as List<dynamic>;
|
|
|
|
List<String> messages = [];
|
|
|
|
for (dynamic error in errors) {
|
|
messages.add(error.toString());
|
|
}
|
|
|
|
return messages;
|
|
}
|
|
|
|
// Is this field required?
|
|
bool get required => (getParameter("required") ?? false) as bool;
|
|
|
|
String get type => (getParameter("type") ?? "").toString();
|
|
|
|
String get label => (getParameter("label") ?? "").toString();
|
|
|
|
String get helpText => (getParameter("help_text") ?? "").toString();
|
|
|
|
String get placeholderText => (getParameter("placeholder") ?? "").toString();
|
|
|
|
List<dynamic> get choices => (getParameter("choices") ?? []) as List<dynamic>;
|
|
|
|
Future<void> loadInitialData() async {
|
|
|
|
// Only for "related fields"
|
|
if (type != "related field") {
|
|
return;
|
|
}
|
|
|
|
// Null value? No point!
|
|
if (value == null) {
|
|
return;
|
|
}
|
|
|
|
int? pk = int.tryParse(value.toString());
|
|
|
|
if (pk == null) {
|
|
return;
|
|
}
|
|
|
|
String url = api_url + "/" + pk.toString() + "/";
|
|
|
|
final APIResponse response = await InvenTreeAPI().get(
|
|
url,
|
|
params: filters,
|
|
);
|
|
|
|
if (response.successful()) {
|
|
initial_data = response.data;
|
|
}
|
|
}
|
|
|
|
// Construct a widget for this input
|
|
Widget constructField(BuildContext context) {
|
|
|
|
switch (type) {
|
|
case "string":
|
|
case "url":
|
|
return _constructString();
|
|
case "boolean":
|
|
return _constructBoolean();
|
|
case "related field":
|
|
return _constructRelatedField();
|
|
case "float":
|
|
case "decimal":
|
|
return _constructFloatField();
|
|
case "choice":
|
|
return _constructChoiceField();
|
|
case "file upload":
|
|
case "image upload":
|
|
return _constructFileField();
|
|
case "date":
|
|
return _constructDateField();
|
|
case "barcode":
|
|
return _constructBarcodeField(context);
|
|
default:
|
|
return ListTile(
|
|
title: Text(
|
|
"Unsupported field type: '${type}' for field '${name}'",
|
|
style: TextStyle(
|
|
color: COLOR_DANGER,
|
|
fontStyle: FontStyle.italic),
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Field for capturing a barcode
|
|
Widget _constructBarcodeField(BuildContext context) {
|
|
|
|
TextEditingController controller = TextEditingController();
|
|
|
|
String barcode = (value ?? "").toString();
|
|
|
|
if (barcode.isEmpty) {
|
|
barcode = L10().barcodeNotAssigned;
|
|
}
|
|
|
|
controller.text = barcode;
|
|
|
|
return InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: required ? label + "*" : label,
|
|
labelStyle: _labelStyle(),
|
|
helperText: helpText,
|
|
helperStyle: _helperStyle(),
|
|
hintText: placeholderText,
|
|
),
|
|
child: ListTile(
|
|
title: TextField(
|
|
readOnly: true,
|
|
controller: controller,
|
|
),
|
|
trailing: IconButton(
|
|
icon: Icon(TablerIcons.qrcode),
|
|
onPressed: () async {
|
|
var handler = UniqueBarcodeHandler((String hash) {
|
|
controller.text = hash;
|
|
data["value"] = hash;
|
|
|
|
barcodeSuccess(L10().barcodeAssigned);
|
|
});
|
|
|
|
scanBarcode(context, handler: handler);
|
|
},
|
|
),
|
|
)
|
|
);
|
|
|
|
}
|
|
|
|
// Field for displaying and selecting dates
|
|
Widget _constructDateField() {
|
|
|
|
DateTime? currentDate = DateTime.tryParse((value ?? "")as String);
|
|
|
|
return InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
labelStyle: _labelStyle(),
|
|
helperStyle: _helperStyle(),
|
|
helperText: helpText,
|
|
),
|
|
child: DateTimeField(
|
|
format: DateFormat("yyyy-MM-dd"),
|
|
initialValue: currentDate,
|
|
onChanged: (DateTime? time) {
|
|
// Save the time string
|
|
if (time == null) {
|
|
data["value"] = null;
|
|
} else {
|
|
data["value"] = time.toString().split(" ").first;
|
|
}
|
|
},
|
|
onShowPicker: (context, value) async {
|
|
final time = await showDatePicker(
|
|
context: context,
|
|
initialDate: currentDate ?? DateTime.now(),
|
|
firstDate: DateTime(1900),
|
|
lastDate: DateTime(2100),
|
|
);
|
|
|
|
return time;
|
|
},
|
|
)
|
|
);
|
|
|
|
}
|
|
|
|
|
|
// Field for selecting and uploading files
|
|
Widget _constructFileField() {
|
|
|
|
TextEditingController controller = TextEditingController();
|
|
|
|
controller.text = (attachedfile?.path ?? L10().attachmentSelect).split("/").last;
|
|
|
|
return InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: label,
|
|
labelStyle: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
|
),
|
|
child: ListTile(
|
|
title: TextField(
|
|
readOnly: true,
|
|
controller: controller,
|
|
),
|
|
trailing: IconButton(
|
|
icon: Icon(TablerIcons.circle_plus),
|
|
onPressed: () async {
|
|
FilePickerDialog.pickFile(
|
|
message: L10().attachmentSelect,
|
|
onPicked: (file) {
|
|
// Display the filename
|
|
controller.text = file.path.split("/").last;
|
|
|
|
// Save the file
|
|
attachedfile = file;
|
|
}
|
|
);
|
|
},
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
// Field for selecting from multiple choice options
|
|
Widget _constructChoiceField() {
|
|
|
|
dynamic initial;
|
|
|
|
// Check if the current value is within the allowed values
|
|
for (var opt in choices) {
|
|
if (opt["value"] == value) {
|
|
initial = opt;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return DropdownSearch<dynamic>(
|
|
popupProps: PopupProps.bottomSheet(
|
|
showSelectedItems: false,
|
|
searchFieldProps: TextFieldProps(
|
|
autofocus: true
|
|
)
|
|
),
|
|
selectedItem: initial,
|
|
items: choices,
|
|
dropdownDecoratorProps: DropDownDecoratorProps(
|
|
dropdownSearchDecoration: InputDecoration(
|
|
labelText: label,
|
|
hintText: helpText,
|
|
)),
|
|
onChanged: null,
|
|
clearButtonProps: ClearButtonProps(isVisible: !required),
|
|
itemAsString: (dynamic item) {
|
|
return (item["display_name"] ?? "") as String;
|
|
},
|
|
onSaved: (item) {
|
|
if (item == null) {
|
|
data["value"] = null;
|
|
} else {
|
|
data["value"] = item["value"];
|
|
}
|
|
});
|
|
}
|
|
|
|
// Construct a floating point numerical input field
|
|
Widget _constructFloatField() {
|
|
|
|
// Initial value: try to cast to a valid number
|
|
String initial = "";
|
|
|
|
double? initialNumber = double.tryParse(value.toString());
|
|
|
|
if (initialNumber != null) {
|
|
initial = simpleNumberString(initialNumber);
|
|
}
|
|
|
|
return TextFormField(
|
|
decoration: InputDecoration(
|
|
labelText: required ? label + "*" : label,
|
|
labelStyle: _labelStyle(),
|
|
helperText: helpText,
|
|
helperStyle: _helperStyle(),
|
|
hintText: placeholderText,
|
|
),
|
|
initialValue: initial,
|
|
keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true),
|
|
validator: (value) {
|
|
value = value?.trim() ?? "";
|
|
|
|
// Allow empty numbers, *if* this field is not required
|
|
if (value.isEmpty && !required) {
|
|
return null;
|
|
}
|
|
|
|
double? quantity = double.tryParse(value.toString());
|
|
|
|
if (quantity == null) {
|
|
return L10().numberInvalid;
|
|
}
|
|
|
|
return null;
|
|
},
|
|
onSaved: (val) {
|
|
data["value"] = val;
|
|
},
|
|
);
|
|
|
|
}
|
|
|
|
// Construct an input for a related field
|
|
Widget _constructRelatedField() {
|
|
return DropdownSearch<dynamic>(
|
|
popupProps: PopupProps.bottomSheet(
|
|
showSelectedItems: true,
|
|
isFilterOnline: true,
|
|
showSearchBox: true,
|
|
itemBuilder: (context, item, isSelected) {
|
|
return _renderRelatedField(name, item, isSelected, true);
|
|
},
|
|
emptyBuilder: (context, item) {
|
|
return _renderEmptyResult();
|
|
},
|
|
searchFieldProps: TextFieldProps(
|
|
autofocus: true
|
|
)
|
|
),
|
|
selectedItem: initial_data,
|
|
asyncItems: (String filter) async {
|
|
Map<String, String> _filters = {
|
|
..._relatedFieldFilters(),
|
|
...filters,
|
|
};
|
|
|
|
_filters["search"] = filter;
|
|
_filters["offset"] = "0";
|
|
_filters["limit"] = "25";
|
|
|
|
final APIResponse response = await InvenTreeAPI().get(api_url, params: _filters);
|
|
|
|
if (response.isValid()) {
|
|
return response.resultsList();
|
|
} else {
|
|
return [];
|
|
}
|
|
},
|
|
clearButtonProps: ClearButtonProps(
|
|
isVisible: !required
|
|
),
|
|
dropdownDecoratorProps: DropDownDecoratorProps(
|
|
dropdownSearchDecoration: InputDecoration(
|
|
labelText: label,
|
|
hintText: helpText,
|
|
)),
|
|
onChanged: null,
|
|
itemAsString: (dynamic item) {
|
|
Map<String, dynamic> data = item as Map<String, dynamic>;
|
|
|
|
switch (model) {
|
|
case InvenTreePart.MODEL_TYPE:
|
|
return InvenTreePart.fromJson(data).fullname;
|
|
case InvenTreeCompany.MODEL_TYPE:
|
|
return InvenTreeCompany.fromJson(data).name;
|
|
case InvenTreePurchaseOrder.MODEL_TYPE:
|
|
return InvenTreePurchaseOrder.fromJson(data).reference;
|
|
case InvenTreeSalesOrder.MODEL_TYPE:
|
|
return InvenTreeSalesOrder.fromJson(data).reference;
|
|
case InvenTreePartCategory.MODEL_TYPE:
|
|
return InvenTreePartCategory.fromJson(data).pathstring;
|
|
case InvenTreeStockLocation.MODEL_TYPE:
|
|
return InvenTreeStockLocation.fromJson(data).pathstring;
|
|
default:
|
|
return "itemAsString not implemented for '${model}'";
|
|
}
|
|
},
|
|
dropdownBuilder: (context, item) {
|
|
return _renderRelatedField(name, item, true, false);
|
|
},
|
|
onSaved: (item) {
|
|
if (item != null) {
|
|
data["value"] = item["pk"];
|
|
} else {
|
|
data["value"] = null;
|
|
}
|
|
},
|
|
compareFn: (dynamic item, dynamic selectedItem) {
|
|
// Comparison is based on the PK value
|
|
|
|
if (item == null || selectedItem == null) {
|
|
return false;
|
|
}
|
|
|
|
bool result = false;
|
|
|
|
try {
|
|
result = item["pk"].toString() == selectedItem["pk"].toString();
|
|
} catch (error) {
|
|
// Catch any conversion errors
|
|
result = false;
|
|
}
|
|
|
|
return result;
|
|
});
|
|
}
|
|
|
|
// Construct a set of custom filters for the dropdown search
|
|
Map<String, String> _relatedFieldFilters() {
|
|
|
|
switch (model) {
|
|
case InvenTreeSupplierPart.MODEL_TYPE:
|
|
return InvenTreeSupplierPart().defaultListFilters();
|
|
case InvenTreeStockItem.MODEL_TYPE:
|
|
return InvenTreeStockItem().defaultListFilters();
|
|
default:
|
|
break;
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
// Render a "related field" based on the "model" type
|
|
Widget _renderRelatedField(String fieldName, dynamic item, bool selected, bool extended) {
|
|
|
|
// Convert to JSON
|
|
Map<String, dynamic> data = {};
|
|
|
|
try {
|
|
if (item is Map<String, dynamic>) {
|
|
data = Map<String, dynamic>.from(item);
|
|
} else {
|
|
data = {};
|
|
}
|
|
} catch (error, stackTrace) {
|
|
data = {};
|
|
|
|
sentryReportError(
|
|
"_renderRelatedField", error, stackTrace,
|
|
context: {
|
|
"method": "_renderRelateField",
|
|
"field_name": fieldName,
|
|
"item": item.toString(),
|
|
"selected": selected.toString(),
|
|
"extended": extended.toString(),
|
|
}
|
|
);
|
|
}
|
|
|
|
switch (model) {
|
|
case InvenTreePart.MODEL_TYPE:
|
|
var part = InvenTreePart.fromJson(data);
|
|
|
|
return ListTile(
|
|
title: Text(
|
|
part.fullname,
|
|
style: TextStyle(fontWeight: selected && extended ? FontWeight.bold : FontWeight.normal)
|
|
),
|
|
subtitle: extended ? Text(
|
|
part.description,
|
|
style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal),
|
|
) : null,
|
|
leading: extended ? InvenTreeAPI().getThumbnail(part.thumbnail) : null,
|
|
);
|
|
case InvenTreePartTestTemplate.MODEL_TYPE:
|
|
var template = InvenTreePartTestTemplate.fromJson(data);
|
|
|
|
return ListTile(
|
|
title: Text(template.testName),
|
|
subtitle: Text(template.description),
|
|
);
|
|
case InvenTreeSupplierPart.MODEL_TYPE:
|
|
var part = InvenTreeSupplierPart.fromJson(data);
|
|
|
|
return ListTile(
|
|
title: Text(part.SKU),
|
|
subtitle: Text(part.partName),
|
|
leading: extended ? InvenTreeAPI().getThumbnail(part.partImage) : null,
|
|
trailing: extended && part.supplierImage.isNotEmpty ? InvenTreeAPI().getThumbnail(part.supplierImage) : null,
|
|
);
|
|
case InvenTreePartCategory.MODEL_TYPE:
|
|
|
|
var cat = InvenTreePartCategory.fromJson(data);
|
|
|
|
return ListTile(
|
|
title: Text(
|
|
cat.pathstring,
|
|
style: TextStyle(fontWeight: selected && extended ? FontWeight.bold : FontWeight.normal)
|
|
),
|
|
subtitle: extended ? Text(
|
|
cat.description,
|
|
style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal),
|
|
) : null,
|
|
);
|
|
case InvenTreeStockItem.MODEL_TYPE:
|
|
var item = InvenTreeStockItem.fromJson(data);
|
|
|
|
return ListTile(
|
|
title: Text(
|
|
item.partName,
|
|
),
|
|
leading: InvenTreeAPI().getThumbnail(item.partThumbnail),
|
|
trailing: Text(item.quantityString()),
|
|
);
|
|
case InvenTreeStockLocation.MODEL_TYPE:
|
|
var loc = InvenTreeStockLocation.fromJson(data);
|
|
|
|
return ListTile(
|
|
title: Text(
|
|
loc.pathstring,
|
|
style: TextStyle(fontWeight: selected && extended ? FontWeight.bold : FontWeight.normal)
|
|
),
|
|
subtitle: extended ? Text(
|
|
loc.description,
|
|
style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal),
|
|
) : null,
|
|
);
|
|
case InvenTreeSalesOrderShipment.MODEL_TYPE:
|
|
var shipment = InvenTreeSalesOrderShipment.fromJson(data);
|
|
|
|
return ListTile(
|
|
title: Text(shipment.reference),
|
|
subtitle: Text(shipment.tracking_number),
|
|
trailing: shipment.shipped ? Text(shipment.shipment_date!) : null,
|
|
);
|
|
case "owner":
|
|
String name = (data["name"] ?? "") as String;
|
|
bool isGroup = (data["label"] ?? "") == "group";
|
|
return ListTile(
|
|
title: Text(name),
|
|
leading: Icon(isGroup ? TablerIcons.users : TablerIcons.user),
|
|
);
|
|
case "contact":
|
|
String name = (data["name"] ?? "") as String;
|
|
String role = (data["role"] ?? "") as String;
|
|
return ListTile(
|
|
title: Text(name),
|
|
subtitle: Text(role),
|
|
);
|
|
case InvenTreeCompany.MODEL_TYPE:
|
|
var company = InvenTreeCompany.fromJson(data);
|
|
return ListTile(
|
|
title: Text(company.name),
|
|
subtitle: extended ? Text(company.description) : null,
|
|
leading: InvenTreeAPI().getThumbnail(company.thumbnail)
|
|
);
|
|
case InvenTreeProjectCode.MODEL_TYPE:
|
|
var project_code = InvenTreeProjectCode.fromJson(data);
|
|
return ListTile(
|
|
title: Text(project_code.code),
|
|
subtitle: Text(project_code.description),
|
|
leading: Icon(TablerIcons.list)
|
|
);
|
|
default:
|
|
return ListTile(
|
|
title: Text(
|
|
"Unsupported model",
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
color: COLOR_DANGER
|
|
)
|
|
),
|
|
subtitle: Text("Model '${model}' rendering not supported"),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Construct a widget to instruct the user that no results were found
|
|
Widget _renderEmptyResult() {
|
|
return ListTile(
|
|
leading: Icon(TablerIcons.search),
|
|
title: Text(L10().noResults),
|
|
subtitle: Text(
|
|
L10().queryNoResults,
|
|
style: TextStyle(fontStyle: FontStyle.italic),
|
|
),
|
|
);
|
|
}
|
|
|
|
|
|
// 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,
|
|
labelStyle: _labelStyle(),
|
|
helperText: helpText,
|
|
helperStyle: _helperStyle(),
|
|
hintText: placeholderText,
|
|
),
|
|
readOnly: readOnly,
|
|
maxLines: multiline ? null : 1,
|
|
expands: false,
|
|
initialValue: (value ?? "") as String,
|
|
onSaved: (val) {
|
|
data["value"] = val;
|
|
},
|
|
validator: (value) {
|
|
if (required && (value == null || value.isEmpty)) {
|
|
// return L10().valueCannotBeEmpty;
|
|
}
|
|
|
|
return null;
|
|
},
|
|
);
|
|
}
|
|
|
|
// 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: initial_value,
|
|
tristate: (getParameter("tristate") ?? false) as bool,
|
|
onSaved: (val) {
|
|
data["value"] = val;
|
|
},
|
|
);
|
|
}
|
|
|
|
TextStyle _labelStyle() {
|
|
return TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 18,
|
|
fontFamily: "arial",
|
|
color: hasErrors() ? COLOR_DANGER : null,
|
|
fontStyle: FontStyle.normal,
|
|
);
|
|
}
|
|
|
|
TextStyle _helperStyle() {
|
|
return TextStyle(
|
|
fontStyle: FontStyle.italic,
|
|
color: hasErrors() ? COLOR_DANGER : null,
|
|
);
|
|
}
|
|
|
|
}
|
|
|
|
|
|
/*
|
|
* Extract field options from a returned OPTIONS request
|
|
*/
|
|
Map<String, dynamic> extractFields(APIResponse response) {
|
|
|
|
if (!response.isValid()) {
|
|
return {};
|
|
}
|
|
|
|
var data = response.asMap();
|
|
|
|
if (!data.containsKey("actions")) {
|
|
return {};
|
|
}
|
|
|
|
var actions = response.data["actions"] as Map<String, dynamic>;
|
|
|
|
dynamic result = actions["POST"] ?? actions["PUT"] ?? actions["PATCH"] ?? {};
|
|
|
|
return result as Map<String, dynamic>;
|
|
}
|
|
|
|
/*
|
|
* Extract a field definition (map) from the provided JSON data.
|
|
*
|
|
* Notes:
|
|
* - If the field is a top-level item, the provided "path" may be a simple string (e.g. "quantity"),
|
|
* - If the field is buried in the JSON data, the "path" may use a dotted notation e.g. "items.child.children.quantity"
|
|
*
|
|
* The map "tree" is traversed based on the provided lookup string, which can use dotted notation.
|
|
* This allows complex paths to be used to lookup field information.
|
|
*/
|
|
Map<String, dynamic> extractFieldDefinition(Map<String, dynamic> data, String lookup) {
|
|
|
|
List<String> path = lookup.split(".");
|
|
|
|
// Shadow copy the data for path traversal
|
|
Map<String, dynamic> _data = data;
|
|
|
|
// Iterate through all but the last element of the path
|
|
for (int ii = 0; ii < (path.length - 1); ii++) {
|
|
|
|
String el = path[ii];
|
|
|
|
if (!_data.containsKey(el)) {
|
|
print("Could not find field definition for ${lookup}:");
|
|
print("- Key ${el} missing at index ${ii}");
|
|
return {};
|
|
}
|
|
|
|
try {
|
|
_data = _data[el] as Map<String, dynamic>;
|
|
} catch (error, stackTrace) {
|
|
print("Could not find sub-field element '${el}' for ${lookup}:");
|
|
print(error.toString());
|
|
|
|
// Report the error
|
|
sentryReportError(
|
|
"apiForm.extractFieldDefinition : path traversal",
|
|
error, stackTrace,
|
|
context: {
|
|
"path": path.toString(),
|
|
"el": el,
|
|
}
|
|
);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
String el = path.last;
|
|
|
|
if (!_data.containsKey(el)) {
|
|
return {};
|
|
} else {
|
|
|
|
try {
|
|
Map<String, dynamic> definition = _data[el] as Map<String, dynamic>;
|
|
|
|
return definition;
|
|
} catch (error, stacktrace) {
|
|
print("Could not find field definition for ${lookup}");
|
|
print(error.toString());
|
|
|
|
// Report the error
|
|
sentryReportError(
|
|
"apiForm.extractFieldDefinition : as map",
|
|
error, stacktrace,
|
|
context: {
|
|
"el": el.toString(),
|
|
}
|
|
);
|
|
|
|
return {};
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
|
|
/*
|
|
* Launch an API-driven form,
|
|
* which uses the OPTIONS metadata (at the provided URL)
|
|
* to determine how the form elements should be rendered!
|
|
*
|
|
* @param title is the title text to display on the form
|
|
* @param url is the API URl to make the OPTIONS request to
|
|
* @param fields is a map of fields to display (with optional overrides)
|
|
* @param modelData is the (optional) existing modelData
|
|
* @param method is the HTTP method to use to send the form data to the server (e.g. POST / PATCH)
|
|
*/
|
|
|
|
Future<void> launchApiForm(
|
|
BuildContext context, String title, String url, Map<String, dynamic> fields,
|
|
{
|
|
String fileField = "",
|
|
Map<String, dynamic> modelData = const {},
|
|
String method = "PATCH",
|
|
Function(Map<String, dynamic>)? onSuccess,
|
|
Function? onCancel,
|
|
IconData icon = TablerIcons.device_floppy
|
|
}) async {
|
|
|
|
showLoadingOverlay();
|
|
|
|
// List of fields defined by the server
|
|
Map<String, dynamic> serverFields = {};
|
|
|
|
if (url.isNotEmpty) {
|
|
|
|
var options = await InvenTreeAPI().options(url);
|
|
|
|
// Invalid response from server
|
|
if (!options.isValid()) {
|
|
hideLoadingOverlay();
|
|
return;
|
|
}
|
|
|
|
serverFields = extractFields(options);
|
|
|
|
if (serverFields.isEmpty) {
|
|
// User does not have permission to perform this action
|
|
showSnackIcon(
|
|
L10().response403,
|
|
icon: TablerIcons.user_x,
|
|
);
|
|
|
|
hideLoadingOverlay();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Construct a list of APIFormField objects
|
|
List<APIFormField> formFields = [];
|
|
|
|
APIFormField field;
|
|
|
|
for (String fieldName in fields.keys) {
|
|
|
|
dynamic data = fields[fieldName];
|
|
|
|
Map<String, dynamic> fieldData = {};
|
|
|
|
if (data is Map) {
|
|
fieldData = Map<String, dynamic>.from(data);
|
|
}
|
|
|
|
// Iterate through the provided fields we wish to display
|
|
|
|
field = APIFormField(fieldName, fieldData);
|
|
|
|
// Extract the definition of this field from the data received from the server
|
|
field.definition = extractFieldDefinition(serverFields, field.lookupPath);
|
|
|
|
// Skip fields with empty definitions
|
|
if (url.isNotEmpty && field.definition.isEmpty) {
|
|
print("Warning: Empty field definition for field '${fieldName}'");
|
|
}
|
|
|
|
// Add instance value to the field
|
|
dynamic model_value = modelData[fieldName];
|
|
|
|
if (model_value != null) {
|
|
field.data["instance_value"] = model_value;
|
|
|
|
if (field.data["value"] == null) {
|
|
field.data["value"] = model_value;
|
|
}
|
|
}
|
|
formFields.add(field);
|
|
}
|
|
|
|
// Grab existing data for each form field
|
|
for (var field in formFields) {
|
|
await field.loadInitialData();
|
|
}
|
|
|
|
hideLoadingOverlay();
|
|
|
|
// Now, launch a new widget!
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => APIFormWidget(
|
|
title,
|
|
url,
|
|
formFields,
|
|
method,
|
|
onSuccess: onSuccess,
|
|
fileField: fileField,
|
|
icon: icon,
|
|
))
|
|
);
|
|
}
|
|
|
|
|
|
class APIFormWidget extends StatefulWidget {
|
|
|
|
const APIFormWidget(
|
|
this.title,
|
|
this.url,
|
|
this.fields,
|
|
this.method,
|
|
{
|
|
Key? key,
|
|
this.onSuccess,
|
|
this.fileField = "",
|
|
this.icon = TablerIcons.device_floppy,
|
|
}
|
|
) : super(key: key);
|
|
|
|
//! Form title to display
|
|
final String title;
|
|
|
|
//! API URL
|
|
final String url;
|
|
|
|
//! API method
|
|
final String method;
|
|
|
|
final String fileField;
|
|
|
|
// Icon
|
|
final IconData icon;
|
|
|
|
final List<APIFormField> fields;
|
|
|
|
final Function(Map<String, dynamic>)? onSuccess;
|
|
|
|
@override
|
|
_APIFormWidgetState createState() => _APIFormWidgetState();
|
|
|
|
}
|
|
|
|
|
|
class _APIFormWidgetState extends State<APIFormWidget> {
|
|
|
|
_APIFormWidgetState() : super();
|
|
|
|
final _formKey = GlobalKey<FormState>();
|
|
|
|
List<String> nonFieldErrors = [];
|
|
|
|
bool spacerRequired = false;
|
|
|
|
List<Widget> _buildForm() {
|
|
|
|
List<Widget> widgets = [];
|
|
|
|
// Display non-field errors first
|
|
if (nonFieldErrors.isNotEmpty) {
|
|
for (String error in nonFieldErrors) {
|
|
widgets.add(
|
|
ListTile(
|
|
title: Text(
|
|
error,
|
|
style: TextStyle(
|
|
color: COLOR_DANGER,
|
|
),
|
|
),
|
|
leading: Icon(
|
|
TablerIcons.exclamation_circle,
|
|
color: COLOR_DANGER
|
|
),
|
|
)
|
|
);
|
|
}
|
|
|
|
widgets.add(Divider(height: 5));
|
|
|
|
}
|
|
|
|
for (var field in widget.fields) {
|
|
|
|
if (field.hidden) {
|
|
continue;
|
|
}
|
|
|
|
// Add divider before some widgets
|
|
if (spacerRequired) {
|
|
switch (field.type) {
|
|
case "related field":
|
|
case "choice":
|
|
widgets.add(Divider(height: 15));
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
widgets.add(field.constructField(context));
|
|
|
|
if (field.hasErrors()) {
|
|
for (String error in field.errorMessages()) {
|
|
widgets.add(
|
|
ListTile(
|
|
title: Text(
|
|
error,
|
|
style: TextStyle(
|
|
color: COLOR_DANGER,
|
|
fontStyle: FontStyle.italic,
|
|
fontSize: 16,
|
|
),
|
|
)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Add divider after some widgets
|
|
switch (field.type) {
|
|
case "related field":
|
|
case "choice":
|
|
widgets.add(Divider(height: 15));
|
|
spacerRequired = false;
|
|
break;
|
|
default:
|
|
spacerRequired = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return widgets;
|
|
}
|
|
|
|
Future<APIResponse> _submit(Map<String, dynamic> data) async {
|
|
|
|
// If a file upload is required, we have to handle the submission differently
|
|
if (widget.fileField.isNotEmpty) {
|
|
|
|
// Pop the "file" field
|
|
data.remove(widget.fileField);
|
|
|
|
for (var field in widget.fields) {
|
|
if (field.name == widget.fileField) {
|
|
|
|
File? file = field.attachedfile;
|
|
|
|
if (file != null) {
|
|
|
|
// A valid file has been supplied
|
|
final response = await InvenTreeAPI().uploadFile(
|
|
widget.url,
|
|
file,
|
|
name: widget.fileField,
|
|
fields: data,
|
|
);
|
|
|
|
return response;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (widget.method == "POST") {
|
|
|
|
showLoadingOverlay();
|
|
final response = await InvenTreeAPI().post(
|
|
widget.url,
|
|
body: data,
|
|
expectedStatusCode: null
|
|
);
|
|
hideLoadingOverlay();
|
|
|
|
return response;
|
|
|
|
} else {
|
|
showLoadingOverlay();
|
|
final response = await InvenTreeAPI().patch(
|
|
widget.url,
|
|
body: data,
|
|
expectedStatusCode: null
|
|
);
|
|
hideLoadingOverlay();
|
|
|
|
return response;
|
|
}
|
|
}
|
|
|
|
void extractNonFieldErrors(APIResponse response) {
|
|
|
|
List<String> errors = [];
|
|
|
|
Map<String, dynamic> data = response.asMap();
|
|
|
|
// Potential keys representing non-field errors
|
|
List<String> keys = [
|
|
"__all__",
|
|
"non_field_errors",
|
|
"errors",
|
|
];
|
|
|
|
for (String key in keys) {
|
|
if (data.containsKey(key)) {
|
|
dynamic result = data[key];
|
|
|
|
if (result is String) {
|
|
errors.add(result);
|
|
} else if (result is List) {
|
|
for (dynamic element in result) {
|
|
errors.add(element.toString());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
nonFieldErrors = errors;
|
|
}
|
|
|
|
/* Check for errors relating to an *unhandled* field name
|
|
* These errors will not be displayed and potentially confuse the user
|
|
* So, we need to know if these are ever happening
|
|
*/
|
|
void checkInvalidErrors(APIResponse response) {
|
|
var errors = response.asMap();
|
|
|
|
for (String fieldName in errors.keys) {
|
|
|
|
bool match = false;
|
|
|
|
switch (fieldName) {
|
|
case "__all__":
|
|
case "non_field_errors":
|
|
case "errors":
|
|
// ignore these global fields
|
|
match = true;
|
|
continue;
|
|
default:
|
|
for (var field in widget.fields) {
|
|
|
|
// Hidden fields can't display errors, so we won't match
|
|
if (field.hidden) {
|
|
continue;
|
|
}
|
|
|
|
if (field.name == fieldName) {
|
|
// Direct Match found!
|
|
match = true;
|
|
break;
|
|
} else if (field.parent == fieldName) {
|
|
|
|
var error = errors[fieldName];
|
|
|
|
if (error is List) {
|
|
for (var el in error) {
|
|
if (el is Map && el.containsKey(field.name)) {
|
|
match = true;
|
|
break;
|
|
}
|
|
}
|
|
} else if (error is Map && error.containsKey(field.name)) {
|
|
match = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
if (!match) {
|
|
// Match for an unknown / unsupported field
|
|
sentryReportMessage(
|
|
"API form returned error for unsupported field",
|
|
context: {
|
|
"url": response.url,
|
|
"status_code": response.statusCode.toString(),
|
|
"field": fieldName,
|
|
"error_message": response.data.toString(),
|
|
}
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Submit the form data to the server, and handle the results
|
|
*/
|
|
Future<void> _save(BuildContext context) async {
|
|
|
|
// Package up the form data
|
|
Map<String, dynamic> data = {};
|
|
|
|
// Iterate through and find "simple" top-level fields
|
|
|
|
for (var field in widget.fields) {
|
|
|
|
if (field.readOnly) {
|
|
continue;
|
|
}
|
|
|
|
if (field.isSimple) {
|
|
// Simple top-level field data
|
|
data[field.name] = field.data["value"];
|
|
} else {
|
|
// Not so simple... (WHY DID I MAKE THE API SO COMPLEX?)
|
|
if (field.parent.isNotEmpty) {
|
|
|
|
// TODO: This is a dirty hack, there *must* be a cleaner way?!
|
|
|
|
dynamic parent = data[field.parent] ?? {};
|
|
|
|
// In the case of a "nested" object, we need to extract the first item
|
|
if (parent is List) {
|
|
parent = parent.first;
|
|
}
|
|
|
|
parent[field.name] = field.data["value"];
|
|
|
|
// Nested fields must be handled as an array!
|
|
// For now, we only allow single length nested fields
|
|
if (field.nested) {
|
|
parent = [parent];
|
|
}
|
|
|
|
data[field.parent] = parent;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run custom onSuccess function
|
|
var successFunc = widget.onSuccess;
|
|
|
|
// An "empty" URL means we don't want to submit the form anywhere
|
|
// Perhaps we just want to process the data?
|
|
if (widget.url.isEmpty) {
|
|
// Hide the form
|
|
Navigator.pop(context);
|
|
|
|
if (successFunc != null) {
|
|
// Return the raw "submitted" data, rather than the server response
|
|
successFunc(data);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
final response = await _submit(data);
|
|
|
|
if (!response.isValid()) {
|
|
showServerError(widget.url, L10().serverError, L10().responseInvalid);
|
|
return;
|
|
}
|
|
|
|
switch (response.statusCode) {
|
|
case 200:
|
|
case 201:
|
|
// Form was successfully validated by the server
|
|
|
|
// Hide this form
|
|
Navigator.pop(context);
|
|
|
|
if (successFunc != null) {
|
|
|
|
// Ensure the response is a valid JSON structure
|
|
Map<String, dynamic> json = {};
|
|
|
|
var data = response.asMap();
|
|
|
|
for (String key in data.keys) {
|
|
json[key.toString()] = data[key];
|
|
}
|
|
|
|
successFunc(json);
|
|
}
|
|
return;
|
|
case 400:
|
|
// Form submission / validation error
|
|
showSnackIcon(
|
|
L10().formError,
|
|
success: false
|
|
);
|
|
|
|
// Update field errors
|
|
for (var field in widget.fields) {
|
|
field.extractErrorMessages(response);
|
|
}
|
|
|
|
extractNonFieldErrors(response);
|
|
checkInvalidErrors(response);
|
|
break;
|
|
case 401:
|
|
showSnackIcon(
|
|
"401: " + L10().response401,
|
|
success: false
|
|
);
|
|
break;
|
|
case 403:
|
|
showSnackIcon(
|
|
"403: " + L10().response403,
|
|
success: false,
|
|
);
|
|
break;
|
|
case 404:
|
|
showSnackIcon(
|
|
"404: " + L10().response404,
|
|
success: false,
|
|
);
|
|
break;
|
|
case 405:
|
|
showSnackIcon(
|
|
"405: " + L10().response405,
|
|
success: false,
|
|
);
|
|
break;
|
|
case 500:
|
|
showSnackIcon(
|
|
"500: " + L10().response500,
|
|
success: false,
|
|
);
|
|
break;
|
|
default:
|
|
showSnackIcon(
|
|
"${response.statusCode}: " + L10().responseInvalid,
|
|
success: false,
|
|
);
|
|
break;
|
|
}
|
|
|
|
setState(() {
|
|
// Refresh the form
|
|
});
|
|
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(widget.title),
|
|
backgroundColor: COLOR_APP_BAR,
|
|
actions: [
|
|
IconButton(
|
|
icon: Icon(widget.icon),
|
|
onPressed: () {
|
|
|
|
if (_formKey.currentState!.validate()) {
|
|
_formKey.currentState!.save();
|
|
|
|
_save(context);
|
|
}
|
|
},
|
|
)
|
|
]
|
|
),
|
|
body: Form(
|
|
key: _formKey,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: _buildForm(),
|
|
),
|
|
padding: EdgeInsets.all(16),
|
|
)
|
|
)
|
|
);
|
|
|
|
}
|
|
}
|