mirror of
https://github.com/inventree/inventree-app.git
synced 2025-12-03 18:59:50 +00:00
Modern Label printing (#724)
* Basic widget * Redirect label printing action * Refactor label printing code * Construct form elements * Refactor to allow re-use of forms * Basic rendering of label printing form * Remove dead code * Pass custom handler through to form context * Refactoring API forms: - Allow custom pk field name - Add callback when values change * linting * Dynamically rebuild form * Handle nested fields * Handle label printing status * Run dart format * Update release notes * Remove unused var * Enable close icon * Handle initial plugin default value * Store default values: - Selected template (per label type) - Selected printing plugin * Dart format * Fix dart linting * use setter * Just use a public field
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
### 0.21.0 - November 2025
|
||||
---
|
||||
|
||||
- Support label printing again, fixing issues with new printing API
|
||||
- Adds zoom controller for barcode scanner camera view
|
||||
- Display default stock location in Part detail page
|
||||
- Display stock information in SupplierPart detail page
|
||||
|
||||
17
lib/api.dart
17
lib/api.dart
@@ -7,6 +7,7 @@ import "package:http/http.dart" as http;
|
||||
import "package:http/io_client.dart";
|
||||
import "package:intl/intl.dart";
|
||||
import "package:inventree/main.dart";
|
||||
import "package:inventree/widget/progress.dart";
|
||||
import "package:one_context/one_context.dart";
|
||||
import "package:open_filex/open_filex.dart";
|
||||
import "package:cached_network_image/cached_network_image.dart";
|
||||
@@ -912,6 +913,8 @@ class InvenTreeAPI {
|
||||
|
||||
var client = createClient(url, strictHttps: strictHttps);
|
||||
|
||||
showLoadingOverlay();
|
||||
|
||||
// Attempt to open a connection to the server
|
||||
try {
|
||||
_request = await client
|
||||
@@ -953,6 +956,7 @@ class InvenTreeAPI {
|
||||
await localFile.writeAsBytes(bytes);
|
||||
|
||||
if (openOnDownload) {
|
||||
hideLoadingOverlay();
|
||||
OpenFilex.open(local_path);
|
||||
}
|
||||
} else {
|
||||
@@ -972,6 +976,8 @@ class InvenTreeAPI {
|
||||
stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -1085,8 +1091,15 @@ class InvenTreeAPI {
|
||||
* We send this with the currently selected "locale",
|
||||
* so that (hopefully) the field messages are correctly translated
|
||||
*/
|
||||
Future<APIResponse> options(String url) async {
|
||||
HttpClientRequest? request = await apiRequest(url, "OPTIONS");
|
||||
Future<APIResponse> options(
|
||||
String url, {
|
||||
Map<String, String> params = const {},
|
||||
}) async {
|
||||
HttpClientRequest? request = await apiRequest(
|
||||
url,
|
||||
"OPTIONS",
|
||||
urlParams: params,
|
||||
);
|
||||
|
||||
if (request == null) {
|
||||
// Return an "invalid" APIResponse
|
||||
|
||||
@@ -26,23 +26,52 @@ import "package:inventree/widget/fields.dart";
|
||||
import "package:inventree/widget/progress.dart";
|
||||
import "package:inventree/widget/snacks.dart";
|
||||
|
||||
/*
|
||||
* 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>;
|
||||
}
|
||||
|
||||
/*
|
||||
* Class that represents a single "form field",
|
||||
* defined by the InvenTree API
|
||||
*/
|
||||
class APIFormField {
|
||||
// Constructor
|
||||
APIFormField(this.name, this.data);
|
||||
APIFormField(this.name, this.data, {this.formHandler});
|
||||
|
||||
// File to be uploaded for this filed
|
||||
File? attachedfile;
|
||||
|
||||
APIFormWidgetState? formHandler;
|
||||
|
||||
// Name of this field
|
||||
final String name;
|
||||
|
||||
// JSON data which defines the field
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
// Function to update the value of this field
|
||||
void setFieldValue(dynamic val) {
|
||||
data["value"] = val;
|
||||
formHandler?.onValueChanged(name, value);
|
||||
}
|
||||
|
||||
// JSON field definition provided by the server
|
||||
Map<String, dynamic> definition = {};
|
||||
|
||||
@@ -88,6 +117,8 @@ class APIFormField {
|
||||
}
|
||||
}
|
||||
|
||||
String get pk_field => (getParameter("pk_field") ?? "pk") as String;
|
||||
|
||||
// Get the "api_url" associated with a related field
|
||||
String get api_url => (getParameter("api_url") ?? "") as String;
|
||||
|
||||
@@ -244,18 +275,13 @@ class APIFormField {
|
||||
return;
|
||||
}
|
||||
|
||||
int? pk = int.tryParse(value.toString());
|
||||
|
||||
if (pk == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String url = api_url + "/" + pk.toString() + "/";
|
||||
String url = api_url + "/" + value.toString() + "/";
|
||||
|
||||
final APIResponse response = await InvenTreeAPI().get(url, params: filters);
|
||||
|
||||
if (response.successful()) {
|
||||
initial_data = response.data;
|
||||
formHandler?.onValueChanged(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +295,7 @@ class APIFormField {
|
||||
return _constructBoolean();
|
||||
case "related field":
|
||||
return _constructRelatedField();
|
||||
case "integer":
|
||||
case "float":
|
||||
case "decimal":
|
||||
return _constructFloatField();
|
||||
@@ -318,8 +345,7 @@ class APIFormField {
|
||||
onPressed: () async {
|
||||
var handler = UniqueBarcodeHandler((String hash) {
|
||||
controller.text = hash;
|
||||
data["value"] = hash;
|
||||
|
||||
setFieldValue(hash);
|
||||
barcodeSuccess(L10().barcodeAssigned);
|
||||
});
|
||||
|
||||
@@ -347,9 +373,9 @@ class APIFormField {
|
||||
onChanged: (DateTime? time) {
|
||||
// Save the time string
|
||||
if (time == null) {
|
||||
data["value"] = null;
|
||||
setFieldValue(null);
|
||||
} else {
|
||||
data["value"] = time.toString().split(" ").first;
|
||||
setFieldValue(time.toString().split(" ").first);
|
||||
}
|
||||
},
|
||||
onShowPicker: (context, value) async {
|
||||
@@ -432,9 +458,9 @@ class APIFormField {
|
||||
},
|
||||
onSaved: (item) {
|
||||
if (item == null) {
|
||||
data["value"] = null;
|
||||
setFieldValue(null);
|
||||
} else {
|
||||
data["value"] = item["value"];
|
||||
setFieldValue(item["value"]);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -481,7 +507,7 @@ class APIFormField {
|
||||
return null;
|
||||
},
|
||||
onSaved: (val) {
|
||||
data["value"] = val;
|
||||
setFieldValue(val);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -527,7 +553,20 @@ class APIFormField {
|
||||
hintText: helpText,
|
||||
),
|
||||
),
|
||||
onChanged: null,
|
||||
onChanged: (item) {
|
||||
if (item != null) {
|
||||
setFieldValue(item[pk_field]);
|
||||
} else {
|
||||
setFieldValue(null);
|
||||
}
|
||||
},
|
||||
onSaved: (item) {
|
||||
if (item != null) {
|
||||
setFieldValue(item[pk_field]);
|
||||
} else {
|
||||
setFieldValue(null);
|
||||
}
|
||||
},
|
||||
itemAsString: (dynamic item) {
|
||||
Map<String, dynamic> data = item as Map<String, dynamic>;
|
||||
|
||||
@@ -551,13 +590,6 @@ class APIFormField {
|
||||
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
|
||||
|
||||
@@ -568,7 +600,8 @@ class APIFormField {
|
||||
bool result = false;
|
||||
|
||||
try {
|
||||
result = item["pk"].toString() == selectedItem["pk"].toString();
|
||||
result =
|
||||
item[pk_field].toString() == selectedItem[pk_field].toString();
|
||||
} catch (error) {
|
||||
// Catch any conversion errors
|
||||
result = false;
|
||||
@@ -765,6 +798,18 @@ class APIFormField {
|
||||
so.customer?.thumbnail ?? so.customer?.image ?? "",
|
||||
),
|
||||
);
|
||||
case "labeltemplate":
|
||||
return ListTile(
|
||||
title: Text((data["name"] ?? "").toString()),
|
||||
subtitle: Text((data["description"] ?? "").toString()),
|
||||
);
|
||||
case "pluginconfig":
|
||||
return ListTile(
|
||||
title: Text(
|
||||
(data["meta"]?["human_name"] ?? data["name"] ?? "").toString(),
|
||||
),
|
||||
subtitle: Text((data["meta"]?["description"] ?? "").toString()),
|
||||
);
|
||||
default:
|
||||
return ListTile(
|
||||
title: Text(
|
||||
@@ -810,8 +855,11 @@ class APIFormField {
|
||||
maxLines: multiline ? null : 1,
|
||||
expands: false,
|
||||
initialValue: (value ?? "") as String,
|
||||
onChanged: (val) {
|
||||
setFieldValue(val);
|
||||
},
|
||||
onSaved: (val) {
|
||||
data["value"] = val;
|
||||
setFieldValue(val);
|
||||
},
|
||||
validator: (value) {
|
||||
if (required && (value == null || value.isEmpty)) {
|
||||
@@ -842,7 +890,7 @@ class APIFormField {
|
||||
initial: initial_value,
|
||||
tristate: (getParameter("tristate") ?? false) as bool,
|
||||
onSaved: (val) {
|
||||
data["value"] = val;
|
||||
setFieldValue(val);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -865,27 +913,6 @@ class APIFormField {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*
|
||||
@@ -981,6 +1008,7 @@ Future<void> launchApiForm(
|
||||
Function(Map<String, dynamic>)? onSuccess,
|
||||
bool Function(Map<String, dynamic>)? validate,
|
||||
Function? onCancel,
|
||||
APIFormWidgetState? formHandler,
|
||||
IconData icon = TablerIcons.device_floppy,
|
||||
}) async {
|
||||
showLoadingOverlay();
|
||||
@@ -1041,7 +1069,7 @@ Future<void> launchApiForm(
|
||||
field.data["instance_value"] = model_value;
|
||||
|
||||
if (field.data["value"] == null) {
|
||||
field.data["value"] = model_value;
|
||||
field.setFieldValue(model_value);
|
||||
}
|
||||
}
|
||||
formFields.add(field);
|
||||
@@ -1066,6 +1094,7 @@ Future<void> launchApiForm(
|
||||
onSuccess: onSuccess,
|
||||
validate: validate,
|
||||
fileField: fileField,
|
||||
state: formHandler,
|
||||
icon: icon,
|
||||
),
|
||||
),
|
||||
@@ -1079,6 +1108,7 @@ class APIFormWidget extends StatefulWidget {
|
||||
this.fields,
|
||||
this.method, {
|
||||
Key? key,
|
||||
this.state,
|
||||
this.onSuccess,
|
||||
this.validate,
|
||||
this.fileField = "",
|
||||
@@ -1105,12 +1135,15 @@ class APIFormWidget extends StatefulWidget {
|
||||
|
||||
final bool Function(Map<String, dynamic>)? validate;
|
||||
|
||||
final APIFormWidgetState? state;
|
||||
|
||||
// Default form handler is constructed if none is provided
|
||||
@override
|
||||
_APIFormWidgetState createState() => _APIFormWidgetState();
|
||||
APIFormWidgetState createState() => state ?? APIFormWidgetState();
|
||||
}
|
||||
|
||||
class _APIFormWidgetState extends State<APIFormWidget> {
|
||||
_APIFormWidgetState() : super();
|
||||
class APIFormWidgetState extends State<APIFormWidget> {
|
||||
APIFormWidgetState() : super();
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
@@ -1118,6 +1151,33 @@ class _APIFormWidgetState extends State<APIFormWidget> {
|
||||
|
||||
bool spacerRequired = false;
|
||||
|
||||
// Return a list of all fields used for this form
|
||||
// The default implementation just returns the fields provided to the widget
|
||||
// However, custom form implementations may override this function
|
||||
List<APIFormField> get formFields {
|
||||
final List<APIFormField> fields = widget.fields;
|
||||
|
||||
// Ensure each field has access to this form handler
|
||||
for (var field in fields) {
|
||||
field.formHandler ??= this;
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
// Callback for when a field value is changed
|
||||
// Default implementation does nothing,
|
||||
// but custom form implementations may override this function
|
||||
void onValueChanged(String field, dynamic value) {}
|
||||
|
||||
Future<void> handleSuccess(
|
||||
Map<String, dynamic> submittedData,
|
||||
Map<String, dynamic> responseData,
|
||||
) async {
|
||||
widget.onSuccess?.call(responseData);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
List<Widget> _buildForm() {
|
||||
List<Widget> widgets = [];
|
||||
|
||||
@@ -1135,7 +1195,7 @@ class _APIFormWidgetState extends State<APIFormWidget> {
|
||||
widgets.add(Divider(height: 5));
|
||||
}
|
||||
|
||||
for (var field in widget.fields) {
|
||||
for (var field in formFields) {
|
||||
if (field.hidden) {
|
||||
continue;
|
||||
}
|
||||
@@ -1190,7 +1250,7 @@ class _APIFormWidgetState extends State<APIFormWidget> {
|
||||
// Pop the "file" field
|
||||
data.remove(widget.fileField);
|
||||
|
||||
for (var field in widget.fields) {
|
||||
for (var field in formFields) {
|
||||
if (field.name == widget.fileField) {
|
||||
File? file = field.attachedfile;
|
||||
|
||||
@@ -1275,7 +1335,7 @@ class _APIFormWidgetState extends State<APIFormWidget> {
|
||||
match = true;
|
||||
continue;
|
||||
default:
|
||||
for (var field in widget.fields) {
|
||||
for (var field in formFields) {
|
||||
// Hidden fields can't display errors, so we won't match
|
||||
if (field.hidden) {
|
||||
continue;
|
||||
@@ -1327,7 +1387,7 @@ class _APIFormWidgetState extends State<APIFormWidget> {
|
||||
|
||||
// Iterate through and find "simple" top-level fields
|
||||
|
||||
for (var field in widget.fields) {
|
||||
for (var field in formFields) {
|
||||
if (field.readOnly) {
|
||||
continue;
|
||||
}
|
||||
@@ -1366,20 +1426,11 @@ class _APIFormWidgetState extends State<APIFormWidget> {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
handleSuccess(data, {});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1394,29 +1445,24 @@ class _APIFormWidgetState extends State<APIFormWidget> {
|
||||
case 200:
|
||||
case 201:
|
||||
// Form was successfully validated by the server
|
||||
// Ensure the response is a valid JSON structure
|
||||
Map<String, dynamic> json = {};
|
||||
|
||||
// Hide this form
|
||||
Navigator.pop(context);
|
||||
var responseData = response.asMap();
|
||||
|
||||
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);
|
||||
for (String key in responseData.keys) {
|
||||
json[key.toString()] = responseData[key];
|
||||
}
|
||||
|
||||
handleSuccess(data, json);
|
||||
|
||||
return;
|
||||
case 400:
|
||||
// Form submission / validation error
|
||||
showSnackIcon(L10().formError, success: false);
|
||||
|
||||
// Update field errors
|
||||
for (var field in widget.fields) {
|
||||
for (var field in formFields) {
|
||||
field.extractErrorMessages(response);
|
||||
}
|
||||
|
||||
@@ -1444,6 +1490,22 @@ class _APIFormWidgetState extends State<APIFormWidget> {
|
||||
});
|
||||
}
|
||||
|
||||
// Construct the internal form widget, based on the provided fields
|
||||
Widget buildForm(BuildContext context) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _buildForm(),
|
||||
),
|
||||
padding: EdgeInsets.all(16),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -1463,18 +1525,7 @@ class _APIFormWidgetState extends State<APIFormWidget> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _buildForm(),
|
||||
),
|
||||
padding: EdgeInsets.all(16),
|
||||
),
|
||||
),
|
||||
body: buildForm(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,6 +695,12 @@
|
||||
"keywords": "Keywords",
|
||||
"@keywords": {},
|
||||
|
||||
"labelDriver": "Label Driver",
|
||||
"@labelDriver": {},
|
||||
|
||||
"labelSelectDriver": "Select Label Printer Driver",
|
||||
"@labelSelectDriver": {},
|
||||
|
||||
"labelPrinting": "Label Printing",
|
||||
"@labelPrinting": {},
|
||||
|
||||
|
||||
422
lib/labels.dart
422
lib/labels.dart
@@ -1,11 +1,206 @@
|
||||
import "package:flutter/cupertino.dart";
|
||||
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:inventree/api.dart";
|
||||
import "package:inventree/widget/progress.dart";
|
||||
import "package:inventree/preferences.dart";
|
||||
import "package:inventree/api_form.dart";
|
||||
import "package:inventree/l10.dart";
|
||||
import "package:inventree/widget/progress.dart";
|
||||
import "package:inventree/widget/snacks.dart";
|
||||
|
||||
const String PRINT_LABEL_URL = "api/label/print/";
|
||||
|
||||
/*
|
||||
* Custom form handler for label printing.
|
||||
* Required to manage dynamic form fields.
|
||||
*/
|
||||
class LabelFormWidgetState extends APIFormWidgetState {
|
||||
LabelFormWidgetState() : super();
|
||||
|
||||
List<APIFormField> dynamicFields = [];
|
||||
|
||||
String pluginKey = "";
|
||||
String labelType = "";
|
||||
|
||||
@override
|
||||
List<APIFormField> get formFields {
|
||||
final baseFields = super.formFields;
|
||||
|
||||
if (pluginKey.isEmpty) {
|
||||
// Handle case where default plugin is provided
|
||||
final APIFormField pluginField = baseFields.firstWhere(
|
||||
(field) => field.name == "plugin",
|
||||
);
|
||||
|
||||
if (pluginField.initial_data != null) {
|
||||
pluginKey = pluginField.value.toString();
|
||||
onValueChanged("plugin", pluginKey);
|
||||
}
|
||||
}
|
||||
|
||||
return [...baseFields, ...dynamicFields];
|
||||
}
|
||||
|
||||
@override
|
||||
void onValueChanged(String field, dynamic value) {
|
||||
if (field == "plugin") {
|
||||
onPluginChanged(value.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> handleSuccess(
|
||||
Map<String, dynamic> submittedData,
|
||||
Map<String, dynamic> responseData,
|
||||
) async {
|
||||
super.handleSuccess(submittedData, responseData);
|
||||
|
||||
// Save default values to the database
|
||||
final String? plugin = submittedData["plugin"]?.toString();
|
||||
final int? template = submittedData["template"] as int?;
|
||||
|
||||
// Save default printing plugin
|
||||
if (plugin != null) {
|
||||
InvenTreeSettingsManager().setValue(INV_LABEL_DEFAULT_PLUGIN, plugin);
|
||||
}
|
||||
|
||||
// Save default template for this label type
|
||||
if (labelType.isNotEmpty && template != null) {
|
||||
final defaultTemplates =
|
||||
await InvenTreeSettingsManager().getValue(
|
||||
INV_LABEL_DEFAULT_TEMPLATES,
|
||||
null,
|
||||
)
|
||||
as Map<String, dynamic>?;
|
||||
|
||||
InvenTreeSettingsManager().setValue(INV_LABEL_DEFAULT_TEMPLATES, {
|
||||
...?defaultTemplates,
|
||||
labelType: template,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Re-fetch printing options when the plugin changes
|
||||
*/
|
||||
Future<void> onPluginChanged(String key) async {
|
||||
showLoadingOverlay();
|
||||
|
||||
InvenTreeAPI().options(PRINT_LABEL_URL, params: {"plugin": key}).then((
|
||||
APIResponse response,
|
||||
) {
|
||||
if (response.isValid()) {
|
||||
updateFields(response);
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Callback when the server responds with printing options,
|
||||
* based on the selected printing plugin
|
||||
*/
|
||||
Future<void> updateFields(APIResponse response) async {
|
||||
Map<String, dynamic> printingFields = extractFields(response);
|
||||
|
||||
// Find only the fields which are not in the "base" fields
|
||||
List<APIFormField> uniqueFields = [];
|
||||
|
||||
for (String key in printingFields.keys) {
|
||||
if (super.formFields.any((field) => field.name == key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
dynamic data = printingFields[key];
|
||||
|
||||
Map<String, dynamic> fieldData = {};
|
||||
|
||||
if (data is Map) {
|
||||
fieldData = Map<String, dynamic>.from(data);
|
||||
}
|
||||
|
||||
APIFormField field = APIFormField(key, fieldData);
|
||||
field.definition = extractFieldDefinition(
|
||||
printingFields,
|
||||
field.lookupPath,
|
||||
);
|
||||
|
||||
if (field.type == "dependent field") {
|
||||
// Dependent fields must be handled separately
|
||||
|
||||
// TODO: This should be refactored into api_form.dart
|
||||
dynamic child = field.definition["child"];
|
||||
|
||||
if (child != null && child is Map) {
|
||||
Map<String, dynamic> child_map = child as Map<String, dynamic>;
|
||||
dynamic nested_children = child_map["children"];
|
||||
|
||||
if (nested_children != null && nested_children is Map) {
|
||||
Map<String, dynamic> nested_child_map =
|
||||
nested_children as Map<String, dynamic>;
|
||||
|
||||
for (var field_key in nested_child_map.keys) {
|
||||
field = APIFormField(field_key, nested_child_map);
|
||||
field.definition = extractFieldDefinition(
|
||||
nested_child_map,
|
||||
field_key,
|
||||
);
|
||||
uniqueFields.add(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// This is a "standard" (non-nested) field
|
||||
uniqueFields.add(field);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
dynamicFields = uniqueFields;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handlePrintingSuccess(
|
||||
BuildContext context,
|
||||
Map<String, dynamic> data,
|
||||
int repeatCount,
|
||||
) async {
|
||||
const int MAX_REPEATS = 60;
|
||||
|
||||
int id = (data["pk"] ?? -1) as int;
|
||||
bool complete = (data["complete"] ?? false) as bool;
|
||||
bool error = data["errors"] != null;
|
||||
String? output = data["output"] as String?;
|
||||
|
||||
if (complete) {
|
||||
if (output != null && output.isNotEmpty) {
|
||||
// An output was generated - we can download it!
|
||||
showSnackIcon(L10().downloading, success: true);
|
||||
InvenTreeAPI().downloadFile(output);
|
||||
} else {
|
||||
// Label was offloaded, likely to an external printer
|
||||
showSnackIcon(L10().printLabelSuccess, success: true);
|
||||
}
|
||||
} else if (error) {
|
||||
showSnackIcon(L10().printLabelFailure, success: false);
|
||||
} else if (repeatCount < MAX_REPEATS && id > 0) {
|
||||
// Printing is not yet complete, but we have a valid output ID
|
||||
Future.delayed(Duration(milliseconds: 1000), () async {
|
||||
// Re-query the printing status
|
||||
InvenTreeAPI().get("data-output/$id/").then((response) {
|
||||
if (response.statusCode == 200) {
|
||||
if (response.data is Map<String, dynamic>) {
|
||||
final responseData = response.data as Map<String, dynamic>;
|
||||
handlePrintingSuccess(context, responseData, repeatCount + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Select a particular label, from a provided list of options,
|
||||
* and print against the selected instances.
|
||||
@@ -13,202 +208,73 @@ import "package:inventree/widget/snacks.dart";
|
||||
*/
|
||||
Future<void> selectAndPrintLabel(
|
||||
BuildContext context,
|
||||
List<Map<String, dynamic>> labels,
|
||||
int instanceId,
|
||||
String labelType,
|
||||
String labelQuery,
|
||||
int instanceId,
|
||||
) async {
|
||||
if (!InvenTreeAPI().isConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find a list of available plugins which support label printing
|
||||
var plugins = InvenTreeAPI().getPlugins(mixin: "labels");
|
||||
|
||||
dynamic initial_label;
|
||||
dynamic initial_plugin;
|
||||
|
||||
List<Map<String, dynamic>> label_options = [];
|
||||
List<Map<String, dynamic>> plugin_options = [];
|
||||
|
||||
// Construct list of available label templates
|
||||
for (var label in labels) {
|
||||
String name = (label["name"] ?? "").toString();
|
||||
String description = (label["description"] ?? "").toString();
|
||||
|
||||
if (description.isNotEmpty) {
|
||||
name += " - ${description}";
|
||||
}
|
||||
|
||||
int pk = (label["pk"] ?? -1) as int;
|
||||
|
||||
if (name.isNotEmpty && pk > 0) {
|
||||
label_options.add({"display_name": name, "value": pk});
|
||||
}
|
||||
if (!InvenTreeAPI().supportsModernLabelPrinting) {
|
||||
// Legacy label printing API not supported
|
||||
showSnackIcon("Label printing not supported by server", success: false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (label_options.length == 1) {
|
||||
initial_label = label_options.first["value"];
|
||||
// Fetch default values for label printing
|
||||
|
||||
// Default template
|
||||
final defaultTemplates = await InvenTreeSettingsManager().getValue(
|
||||
INV_LABEL_DEFAULT_TEMPLATES,
|
||||
null,
|
||||
);
|
||||
int? defaultTemplate;
|
||||
|
||||
if (defaultTemplates != null && defaultTemplates is Map<String, dynamic>) {
|
||||
defaultTemplate = defaultTemplates[labelType] as int?;
|
||||
}
|
||||
|
||||
// Construct list of available plugins
|
||||
for (var plugin in plugins) {
|
||||
plugin_options.add({"display_name": plugin.humanName, "value": plugin.key});
|
||||
}
|
||||
|
||||
String selectedPlugin = await InvenTreeAPI().getUserSetting(
|
||||
"LABEL_DEFAULT_PRINTER",
|
||||
// Default plugin
|
||||
final defaultPlugin = await InvenTreeSettingsManager().getValue(
|
||||
INV_LABEL_DEFAULT_PLUGIN,
|
||||
null,
|
||||
);
|
||||
|
||||
if (selectedPlugin.isNotEmpty) {
|
||||
initial_plugin = selectedPlugin;
|
||||
} else if (plugin_options.length == 1) {
|
||||
initial_plugin = plugin_options.first["value"];
|
||||
}
|
||||
|
||||
Map<String, dynamic> fields = {
|
||||
"label": {
|
||||
"label": L10().labelTemplate,
|
||||
"type": "choice",
|
||||
"value": initial_label,
|
||||
"choices": label_options,
|
||||
"required": true,
|
||||
// Specify a default list of fields for printing
|
||||
// The selected plugin may optionally extend this list of fields dynamically
|
||||
Map<String, Map<String, dynamic>> baseFields = {
|
||||
"template": {
|
||||
"default": defaultTemplate,
|
||||
"filters": {
|
||||
"enabled": true,
|
||||
"model_type": labelType,
|
||||
"items": instanceId.toString(),
|
||||
},
|
||||
},
|
||||
"plugin": {
|
||||
"label": L10().pluginPrinter,
|
||||
"type": "choice",
|
||||
"value": initial_plugin,
|
||||
"choices": plugin_options,
|
||||
"required": true,
|
||||
"default": defaultPlugin,
|
||||
"pk_field": "key",
|
||||
"filters": {"enabled": true, "mixin": "labels"},
|
||||
},
|
||||
"items": {
|
||||
"hidden": true,
|
||||
"value": [instanceId],
|
||||
},
|
||||
};
|
||||
|
||||
final formHandler = LabelFormWidgetState();
|
||||
formHandler.labelType = labelType;
|
||||
|
||||
launchApiForm(
|
||||
context,
|
||||
L10().printLabel,
|
||||
"",
|
||||
fields,
|
||||
icon: TablerIcons.printer,
|
||||
validate: (Map<String, dynamic> data) {
|
||||
final template = data["label"];
|
||||
final plugin = data["plugin"];
|
||||
|
||||
if (template == null) {
|
||||
showSnackIcon(L10().labelSelectTemplate, success: false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (plugin == null) {
|
||||
showSnackIcon(L10().labelSelectPrinter, success: false);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
onSuccess: (Map<String, dynamic> data) async {
|
||||
int labelId = (data["label"] ?? -1) as int;
|
||||
var pluginKey = data["plugin"];
|
||||
|
||||
bool result = false;
|
||||
|
||||
if (labelId != -1 && pluginKey != null) {
|
||||
showLoadingOverlay();
|
||||
|
||||
if (InvenTreeAPI().supportsModernLabelPrinting) {
|
||||
// Modern label printing API uses a POST request to a single API endpoint.
|
||||
await InvenTreeAPI()
|
||||
.post(
|
||||
"/label/print/",
|
||||
body: {
|
||||
"plugin": pluginKey,
|
||||
"template": labelId,
|
||||
"items": [instanceId],
|
||||
},
|
||||
)
|
||||
.then((APIResponse response) {
|
||||
if (response.isValid() &&
|
||||
response.statusCode >= 200 &&
|
||||
response.statusCode <= 201) {
|
||||
var data = response.asMap();
|
||||
|
||||
if (data.containsKey("output")) {
|
||||
String? label_file = (data["output"]) as String?;
|
||||
|
||||
if (label_file != null && label_file.isNotEmpty) {
|
||||
// Attempt to open generated file
|
||||
InvenTreeAPI().downloadFile(label_file);
|
||||
}
|
||||
|
||||
result = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Legacy label printing API
|
||||
// Uses a GET request to a specially formed URL which depends on the parameters
|
||||
String url =
|
||||
"/label/${labelType}/${labelId}/print/?${labelQuery}&plugin=${pluginKey}";
|
||||
await InvenTreeAPI().get(url).then((APIResponse response) {
|
||||
if (response.isValid() && response.statusCode == 200) {
|
||||
var data = response.asMap();
|
||||
if (data.containsKey("file")) {
|
||||
var label_file = (data["file"] ?? "") as String;
|
||||
|
||||
// Attempt to open remote file
|
||||
InvenTreeAPI().downloadFile(label_file);
|
||||
result = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
hideLoadingOverlay();
|
||||
|
||||
if (result) {
|
||||
showSnackIcon(L10().printLabelSuccess, success: true);
|
||||
} else {
|
||||
showSnackIcon(L10().printLabelFailure, success: false);
|
||||
}
|
||||
}
|
||||
PRINT_LABEL_URL,
|
||||
baseFields,
|
||||
method: "POST",
|
||||
modelData: {"plugin": defaultPlugin, "template": defaultTemplate},
|
||||
formHandler: formHandler,
|
||||
onSuccess: (data) async {
|
||||
handlePrintingSuccess(context, data, 0);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Discover which label templates are available for a given item
|
||||
*/
|
||||
Future<List<Map<String, dynamic>>> getLabelTemplates(
|
||||
String labelType,
|
||||
Map<String, String> data,
|
||||
) async {
|
||||
if (!InvenTreeAPI().isConnected() ||
|
||||
!InvenTreeAPI().supportsMixin("labels")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter by active plugins
|
||||
data["enabled"] = "true";
|
||||
|
||||
String url = "/label/template/";
|
||||
|
||||
if (InvenTreeAPI().supportsModernLabelPrinting) {
|
||||
data["model_type"] = labelType;
|
||||
} else {
|
||||
// Legacy label printing API endpoint
|
||||
url = "/label/${labelType}/";
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> labels = [];
|
||||
|
||||
await InvenTreeAPI().get(url, params: data).then((APIResponse response) {
|
||||
if (response.isValid() && response.statusCode == 200) {
|
||||
for (var label in response.resultsList()) {
|
||||
if (label is Map<String, dynamic>) {
|
||||
labels.add(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,11 @@ const int SCREEN_ORIENTATION_LANDSCAPE = 2;
|
||||
const String INV_SOUNDS_BARCODE = "barcodeSounds";
|
||||
const String INV_SOUNDS_SERVER = "serverSounds";
|
||||
|
||||
// Label printing settings
|
||||
const String INV_ENABLE_LABEL_PRINTING = "enableLabelPrinting";
|
||||
const String INV_LABEL_DEFAULT_TEMPLATES = "defaultLabelTemplates";
|
||||
const String INV_LABEL_DEFAULT_PRINTER = "defaultLabelPrinter";
|
||||
const String INV_LABEL_DEFAULT_PLUGIN = "defaultLabelPlugin";
|
||||
|
||||
// Part settings
|
||||
const String INV_PART_SHOW_PARAMETERS = "partShowParameters";
|
||||
|
||||
@@ -63,8 +63,6 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
|
||||
|
||||
InvenTreePartPricing? partPricing;
|
||||
|
||||
List<Map<String, dynamic>> labels = [];
|
||||
|
||||
@override
|
||||
String getAppBarTitle() => L10().partDetails;
|
||||
|
||||
@@ -121,19 +119,13 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
if (labels.isNotEmpty) {
|
||||
if (allowLabelPrinting && api.supportsModernLabelPrinting) {
|
||||
actions.add(
|
||||
SpeedDialChild(
|
||||
child: Icon(TablerIcons.printer),
|
||||
label: L10().printLabel,
|
||||
onTap: () async {
|
||||
selectAndPrintLabel(
|
||||
context,
|
||||
labels,
|
||||
widget.part.pk,
|
||||
"part",
|
||||
"part=${widget.part.pk}",
|
||||
);
|
||||
selectAndPrintLabel(context, "part", widget.part.pk);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -271,26 +263,6 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
List<Map<String, dynamic>> _labels = [];
|
||||
allowLabelPrinting &= api.supportsMixin("labels");
|
||||
|
||||
if (allowLabelPrinting) {
|
||||
String model_type = api.supportsModernLabelPrinting
|
||||
? InvenTreePart.MODEL_TYPE
|
||||
: "part";
|
||||
String item_key = api.supportsModernLabelPrinting ? "items" : "part";
|
||||
|
||||
_labels = await getLabelTemplates(model_type, {
|
||||
item_key: widget.part.pk.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
labels = _labels;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _editPartDialog(BuildContext context) {
|
||||
|
||||
@@ -64,6 +64,7 @@ void showSnackIcon(
|
||||
},
|
||||
),
|
||||
backgroundColor: backgroundColor,
|
||||
showCloseIcon: true,
|
||||
action: onAction == null
|
||||
? null
|
||||
: SnackBarAction(
|
||||
|
||||
@@ -38,7 +38,7 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> {
|
||||
|
||||
final InvenTreeStockLocation? location;
|
||||
|
||||
List<Map<String, dynamic>> labels = [];
|
||||
bool allowLabelPrinting = false;
|
||||
|
||||
@override
|
||||
String getAppBarTitle() {
|
||||
@@ -179,19 +179,15 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.location != null && labels.isNotEmpty) {
|
||||
if (widget.location != null &&
|
||||
allowLabelPrinting &&
|
||||
api.supportsModernLabelPrinting) {
|
||||
actions.add(
|
||||
SpeedDialChild(
|
||||
child: Icon(TablerIcons.printer),
|
||||
label: L10().printLabel,
|
||||
onTap: () async {
|
||||
selectAndPrintLabel(
|
||||
context,
|
||||
labels,
|
||||
widget.location!.pk,
|
||||
"location",
|
||||
"location=${widget.location!.pk}",
|
||||
);
|
||||
selectAndPrintLabel(context, "stocklocation", widget.location!.pk);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -236,33 +232,10 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _labels = [];
|
||||
bool allowLabelPrinting = await InvenTreeSettingsManager().getBool(
|
||||
allowLabelPrinting = await InvenTreeSettingsManager().getBool(
|
||||
INV_ENABLE_LABEL_PRINTING,
|
||||
true,
|
||||
);
|
||||
allowLabelPrinting &= api.supportsMixin("labels");
|
||||
|
||||
if (allowLabelPrinting) {
|
||||
if (widget.location != null) {
|
||||
String model_type = api.supportsModernLabelPrinting
|
||||
? InvenTreeStockLocation.MODEL_TYPE
|
||||
: "location";
|
||||
String item_key = api.supportsModernLabelPrinting
|
||||
? "items"
|
||||
: "location";
|
||||
|
||||
_labels = await getLabelTemplates(model_type, {
|
||||
item_key: widget.location!.pk.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
labels = _labels;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _newLocation(BuildContext context) async {
|
||||
|
||||
@@ -128,19 +128,13 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
if (labels.isNotEmpty) {
|
||||
if (allowLabelPrinting && api.supportsModernLabelPrinting) {
|
||||
actions.add(
|
||||
SpeedDialChild(
|
||||
child: Icon(TablerIcons.printer),
|
||||
label: L10().printLabel,
|
||||
onTap: () async {
|
||||
selectAndPrintLabel(
|
||||
context,
|
||||
labels,
|
||||
widget.item.pk,
|
||||
"stock",
|
||||
"item=${widget.item.pk}",
|
||||
);
|
||||
selectAndPrintLabel(context, "stockitem", widget.item.pk);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -196,10 +190,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
|
||||
return actions;
|
||||
}
|
||||
|
||||
// Is label printing enabled for this StockItem?
|
||||
// This will be determined when the widget is loaded
|
||||
List<Map<String, dynamic>> labels = [];
|
||||
|
||||
bool allowLabelPrinting = false;
|
||||
int attachmentCount = 0;
|
||||
|
||||
@override
|
||||
@@ -318,31 +309,10 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _labels = [];
|
||||
bool allowLabelPrinting = await InvenTreeSettingsManager().getBool(
|
||||
allowLabelPrinting = await InvenTreeSettingsManager().getBool(
|
||||
INV_ENABLE_LABEL_PRINTING,
|
||||
true,
|
||||
);
|
||||
allowLabelPrinting &= api.supportsMixin("labels");
|
||||
|
||||
// Request information on labels available for this stock item
|
||||
if (allowLabelPrinting) {
|
||||
String model_type = api.supportsModernLabelPrinting
|
||||
? InvenTreeStockItem.MODEL_TYPE
|
||||
: "stock";
|
||||
String item_key = api.supportsModernLabelPrinting ? "items" : "item";
|
||||
|
||||
// Clear the existing labels list
|
||||
_labels = await getLabelTemplates(model_type, {
|
||||
item_key: widget.item.pk.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
labels = _labels;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the stock item from the database
|
||||
|
||||
Reference in New Issue
Block a user