From 7291dc9272acc6b42c3f2986c86d3b1c281912ab Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 15 Jul 2021 23:58:09 +1000 Subject: [PATCH 01/26] Extract field information from the server --- lib/api_form.dart | 76 ++++++++++++++++++++++++++++++++++++++++ lib/inventree/model.dart | 3 +- 2 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 lib/api_form.dart diff --git a/lib/api_form.dart b/lib/api_form.dart new file mode 100644 index 00000000..21d037d1 --- /dev/null +++ b/lib/api_form.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; + +import 'package:InvenTree/api.dart'; + +/* + * Extract field options from a returned OPTIONS request + */ +Map extractFields(dynamic options) { + + if (options == null) { + return {}; + } + + if (!options.containsKey("actions")) { + return {}; + } + + var actions = options["actions"]; + + return actions["POST"] ?? actions["PUT"] ?? actions["PATCH"] ?? {}; +} + +/* + * Launch an API-driven form, + * which uses the OPTIONS metadata (at the provided URL) + * to determine how the form elements should be rendered! + */ + +Future launchApiForm(String url, Map fields, {String method = "PATCH"}) async { + + dynamic options = await InvenTreeAPI().options(url); + + // null response from server + if (options == null) { + return false; + } + + var availableFields = extractFields(options); + + if (availableFields.isEmpty) { + print("Empty fields {} returned from ${url}"); + return false; + } + + // Iterate through the provided fields we wish to display + for (String fieldName in fields.keys) { + + // Check that the field is actually available at the API endpoint + if (!availableFields.containsKey(fieldName)) { + print("Field '${fieldName}' not available at '${url}'"); + continue; + } + + var remoteField = availableFields[fieldName] ?? {}; + var localField = fields[fieldName] ?? {}; + + // Override defined field parameters, if provided + for (String key in localField.keys) { + // Special consideration + if (key == "filters") { + + } else { + String? val = localField[key]; + + if (val != null) { + remoteField[key] = val; + } + } + } + + print("${fieldName} -> ${remoteField.toString()}"); + + } + + return true; +} \ No newline at end of file diff --git a/lib/inventree/model.dart b/lib/inventree/model.dart index 76d558b3..3cc6b916 100644 --- a/lib/inventree/model.dart +++ b/lib/inventree/model.dart @@ -126,8 +126,7 @@ class InvenTreeModel { } // Return the API detail endpoint for this Model object - String get url => "${URL}/${pk}/"; - + String get url => "${URL}/${pk}/".replaceAll("//", "/"); // Search this Model type in the database Future> search(BuildContext context, String searchTerm, {Map filters = const {}}) async { From 43312691282aeb1de31d83cb6271e33d9e98a02c Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 16 Jul 2021 00:05:28 +1000 Subject: [PATCH 02/26] OOPs I did it again --- lib/api_form.dart | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/lib/api_form.dart b/lib/api_form.dart index 21d037d1..b2626729 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -2,6 +2,38 @@ import 'dart:convert'; import 'package:InvenTree/api.dart'; + +/* + * Class that represents a single "form field", + * defined by the InvenTree API + */ +class APIFormField { + + // Constructor + APIFormField(this.name, this.data); + + // Name of this field + final String name; + + // JSON data which defines the field + final dynamic data; + + // Is this field hidden? + bool get hidden => (data['hidden'] ?? false) as bool; + + // Is this field read only? + bool get readOnly => (data['read_only'] ?? false) as bool; + + // Is this field required? + bool get required => (data['required'] ?? false) as bool; + + String get label => (data['label'] ?? '') as String; + + String get helpText => (data['help_text'] ?? '') as String; + +} + + /* * Extract field options from a returned OPTIONS request */ @@ -42,6 +74,9 @@ Future launchApiForm(String url, Map fields, {String meth return false; } + // Construct a list of APIFormField objects + List formFields = []; + // Iterate through the provided fields we wish to display for (String fieldName in fields.keys) { @@ -68,8 +103,11 @@ Future launchApiForm(String url, Map fields, {String meth } } - print("${fieldName} -> ${remoteField.toString()}"); + formFields.add(APIFormField(fieldName, remoteField)); + } + for (var ff in formFields) { + print("${ff.name} -> ${ff.label} (${ff.helpText})"); } return true; From 6d045c1aac31d5221c5bb4660fe3b50d24d541fc Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 16 Jul 2021 00:48:04 +1000 Subject: [PATCH 03/26] Support boolean and string fields - Pass existing data to the API form to pre-populate --- lib/api_form.dart | 95 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 5 deletions(-) diff --git a/lib/api_form.dart b/lib/api_form.dart index b2626729..186dff56 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -1,7 +1,10 @@ import 'dart:convert'; import 'package:InvenTree/api.dart'; - +import 'package:InvenTree/widget/dialogs.dart'; +import 'package:InvenTree/widget/fields.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; /* * Class that represents a single "form field", @@ -24,13 +27,71 @@ class APIFormField { // Is this field read only? bool get readOnly => (data['read_only'] ?? false) as bool; + // Get the "value" as a string (look for "default" if not available) + dynamic get value => (data['value'] ?? data['default']); + + // Get the "default" as a string + dynamic get defaultValue => data['default']; + + // Return the error message associated with this field + String get errorMessage => data['error']; + // Is this field required? bool get required => (data['required'] ?? false) as bool; - String get label => (data['label'] ?? '') as String; + String get type => (data['type'] ?? '').toString(); - String get helpText => (data['help_text'] ?? '') as String; + String get label => (data['label'] ?? '').toString(); + String get helpText => (data['help_text'] ?? '').toString(); + + // Construct a widget for this input + Widget constructField() { + switch (type) { + case "string": + return _constructString(); + case "boolean": + return _constructBoolean(); + default: + return ListTile( + title: Text("Unsupported field type: '${type}'") + ); + } + } + + // Consturct a string input element + Widget _constructString() { + + return TextFormField( + decoration: InputDecoration( + labelText: required ? label + "*" : label, + hintText: helpText, + ), + initialValue: value ?? '', + onSaved: (val) { + data["value"] = val; + print("${name} -> ${val}"); + }, + validator: (value) { + + // TODO - Custom field validation + }, + ); + } + + // Construct a boolean input element + Widget _constructBoolean() { + + return CheckBoxField( + label: label, + hint: helpText, + initial: value, + onSaved: (val) { + data['value'] = val; + print("${name} -> ${val}"); + }, + ); + } } @@ -56,9 +117,15 @@ Map extractFields(dynamic options) { * 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 launchApiForm(String url, Map fields, {String method = "PATCH"}) async { +Future launchApiForm(String title, String url, Map fields, {Map modelData = const {}, String method = "PATCH"}) async { dynamic options = await InvenTreeAPI().options(url); @@ -103,12 +170,30 @@ Future launchApiForm(String url, Map fields, {String meth } } + // Update fields with existing model data + for (String key in modelData.keys) { + + dynamic value = modelData[key]; + + if (availableFields.containsKey(key)) { + availableFields[key]['value'] = value; + } + } + formFields.add(APIFormField(fieldName, remoteField)); } + List widgets = []; + for (var ff in formFields) { - print("${ff.name} -> ${ff.label} (${ff.helpText})"); + widgets.add(ff.constructField()); } + final formKey = new GlobalKey(); + + showFormDialog(title, fields: widgets, key: formKey, callback: () { + print("submitted, I guess?"); + }); + return true; } \ No newline at end of file From 284c59d8f84157dc910788c48c0cf08d6a3574eb Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 16 Jul 2021 01:04:55 +1000 Subject: [PATCH 04/26] Construct a custom dialog --- lib/api_form.dart | 85 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 65 insertions(+), 20 deletions(-) diff --git a/lib/api_form.dart b/lib/api_form.dart index 186dff56..db081019 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -1,10 +1,11 @@ -import 'dart:convert'; - import 'package:InvenTree/api.dart'; import 'package:InvenTree/widget/dialogs.dart'; import 'package:InvenTree/widget/fields.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:one_context/one_context.dart'; +import 'package:InvenTree/l10.dart'; + /* * Class that represents a single "form field", @@ -70,7 +71,6 @@ class APIFormField { initialValue: value ?? '', onSaved: (val) { data["value"] = val; - print("${name} -> ${val}"); }, validator: (value) { @@ -88,7 +88,6 @@ class APIFormField { initial: value, onSaved: (val) { data['value'] = val; - print("${name} -> ${val}"); }, ); } @@ -125,20 +124,20 @@ Map extractFields(dynamic options) { * @param method is the HTTP method to use to send the form data to the server (e.g. POST / PATCH) */ -Future launchApiForm(String title, String url, Map fields, {Map modelData = const {}, String method = "PATCH"}) async { +Future launchApiForm(String title, String url, Map fields, {Map modelData = const {}, String method = "PATCH"}) async { dynamic options = await InvenTreeAPI().options(url); // null response from server if (options == null) { - return false; + return; } var availableFields = extractFields(options); if (availableFields.isEmpty) { print("Empty fields {} returned from ${url}"); - return false; + return; } // Construct a list of APIFormField objects @@ -158,15 +157,11 @@ Future launchApiForm(String title, String url, Map fields // Override defined field parameters, if provided for (String key in localField.keys) { - // Special consideration + // Special consideration must be taken here! if (key == "filters") { - + // TODO: Custom filter updating } else { - String? val = localField[key]; - - if (val != null) { - remoteField[key] = val; - } + remoteField[key] = localField[key]; } } @@ -186,14 +181,64 @@ Future launchApiForm(String title, String url, Map fields List widgets = []; for (var ff in formFields) { - widgets.add(ff.constructField()); + if (!ff.hidden) { + widgets.add(ff.constructField()); + } } - final formKey = new GlobalKey(); + final _formKey = new GlobalKey(); - showFormDialog(title, fields: widgets, key: formKey, callback: () { - print("submitted, I guess?"); - }); + OneContext().showDialog( + builder: (BuildContext context) { + return AlertDialog( + title: Text(title), + actions: [ + // Cancel button + TextButton( + child: Text(L10().cancel), + onPressed: () { + Navigator.pop(context); + }, + ), + // Save button + TextButton( + child: Text(L10().save), + onPressed: () { + // Validate the form + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); - return true; + // Package up the form data + Map formData = {}; + + for (var field in formFields) { + formData[field.name] = field.value.toString(); + } + + print(formData.toString()); + + // Send the data to the server + + // Respond to error message + + // Dismiss the form + + } + }, + ) + ], + content: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: widgets + ) + ) + ) + ); + } + ); } \ No newline at end of file From ba9f09cd657e6ef0c7ccac0eb79242f79ff114c9 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 16 Jul 2021 08:23:34 +1000 Subject: [PATCH 05/26] stash --- lib/api.dart | 5 +++-- lib/widget/part_detail.dart | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/api.dart b/lib/api.dart index 39d4113d..77906948 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -516,10 +516,11 @@ class InvenTreeAPI { } ); } - - return null; } + // Include the statuscode in the response object + responseData["statusCode"] = response.statusCode; + return responseData; } diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index b13119e8..d301e3f6 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:InvenTree/api_form.dart'; import 'package:InvenTree/widget/part_notes.dart'; import 'package:InvenTree/widget/progress.dart'; import 'package:InvenTree/widget/snacks.dart'; @@ -178,6 +179,24 @@ class _PartDisplayState extends RefreshableState { var _keywords; var _link; + + launchApiForm( + "Edit Part", + part.url, + { + "name": {}, + "description": {}, + "IPN": { + "hidden": true, + "label": "My custom label!", + }, + "active": {}, + }, + modelData: part.jsondata + ); + + return; + showFormDialog(L10().editPart, key: _editPartKey, callback: () { From e470fb60a2b266f3a9c4c0c6d54bc0ca1cdcc62b Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 16 Jul 2021 17:10:03 +1000 Subject: [PATCH 06/26] Fix package names --- lib/api.dart | 10 +++++----- lib/api_form.dart | 9 +++++---- lib/widget/part_detail.dart | 10 +++++----- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/lib/api.dart b/lib/api.dart index 371e3fd9..be6ba708 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -2,20 +2,20 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:http/http.dart' as http; import 'package:intl/intl.dart'; -import 'package:inventree/inventree/sentry.dart'; -import 'package:inventree/user_profile.dart'; -import 'package:inventree/widget/snacks.dart'; import 'package:flutter/cupertino.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + import 'package:inventree/widget/dialogs.dart'; import 'package:inventree/l10.dart'; - -import 'package:http/http.dart' as http; +import 'package:inventree/inventree/sentry.dart'; +import 'package:inventree/user_profile.dart'; +import 'package:inventree/widget/snacks.dart'; /** diff --git a/lib/api_form.dart b/lib/api_form.dart index db081019..fe3b248a 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -1,10 +1,11 @@ -import 'package:InvenTree/api.dart'; -import 'package:InvenTree/widget/dialogs.dart'; -import 'package:InvenTree/widget/fields.dart'; +import 'package:inventree/api.dart'; +import 'package:inventree/widget/dialogs.dart'; +import 'package:inventree/widget/fields.dart'; +import 'package:inventree/l10.dart'; + import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:one_context/one_context.dart'; -import 'package:InvenTree/l10.dart'; /* diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index 327ce958..88bd0b3c 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -1,15 +1,15 @@ import 'dart:io'; -import 'package:InvenTree/api_form.dart'; -import 'package:InvenTree/widget/part_notes.dart'; -import 'package:InvenTree/widget/progress.dart'; -import 'package:InvenTree/widget/snacks.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:inventree/l10.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:inventree/l10.dart'; +import 'package:inventree/api_form.dart'; +import 'package:inventree/widget/part_notes.dart'; +import 'package:inventree/widget/progress.dart'; +import 'package:inventree/widget/snacks.dart'; import 'package:inventree/inventree/part.dart'; import 'package:inventree/widget/full_screen_image.dart'; import 'package:inventree/widget/category_display.dart'; From faf20030e361b58d98f7f1de94940ae3d313c9c2 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 16 Jul 2021 17:30:31 +1000 Subject: [PATCH 07/26] Set error messgaes for form fields --- lib/api.dart | 4 +-- lib/api_form.dart | 90 ++++++++++++++++++++++++++++++++++------------- 2 files changed, 68 insertions(+), 26 deletions(-) diff --git a/lib/api.dart b/lib/api.dart index be6ba708..71841018 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -34,7 +34,7 @@ class APIResponse { dynamic data; // Request is "valid" if a statusCode was returned - bool isValid() => statusCode >= 0; + bool isValid() => (statusCode >= 0) && (statusCode < 500); } @@ -431,7 +431,7 @@ class InvenTreeAPI { // Perform a PATCH request - Future patch(String url, {Map body = const {}, int expectedStatusCode=200}) async { + Future patch(String url, {Map body = const {}, int? expectedStatusCode}) async { var _body = Map(); // Copy across provided data diff --git a/lib/api_form.dart b/lib/api_form.dart index fe3b248a..11c764a6 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -35,8 +35,20 @@ class APIFormField { // Get the "default" as a string dynamic get defaultValue => data['default']; + bool hasErrors() => errorMessages().length > 0; + // Return the error message associated with this field - String get errorMessage => data['error']; + List errorMessages() { + List errors = data['errors'] ?? []; + + List messages = []; + + for (dynamic error in errors) { + messages.add(error.toString()); + } + + return messages; + } // Is this field required? bool get required => (data['required'] ?? false) as bool; @@ -98,17 +110,17 @@ class APIFormField { /* * Extract field options from a returned OPTIONS request */ -Map extractFields(dynamic options) { +Map extractFields(APIResponse response) { - if (options == null) { + if (!response.isValid()) { return {}; } - if (!options.containsKey("actions")) { + if (!response.data.containsKey("actions")) { return {}; } - var actions = options["actions"]; + var actions = response.data["actions"]; return actions["POST"] ?? actions["PUT"] ?? actions["PATCH"] ?? {}; } @@ -127,10 +139,12 @@ Map extractFields(dynamic options) { Future launchApiForm(String title, String url, Map fields, {Map modelData = const {}, String method = "PATCH"}) async { - dynamic options = await InvenTreeAPI().options(url); + var options = await InvenTreeAPI().options(url); - // null response from server - if (options == null) { + final _formKey = new GlobalKey(); + + // Invalid response from server + if (!options.isValid()) { return; } @@ -187,7 +201,49 @@ Future launchApiForm(String title, String url, Map fields } } - final _formKey = new GlobalKey(); + void sendRequest(BuildContext context) async { + + // Package up the form data + Map formData = {}; + + for (var field in formFields) { + formData[field.name] = field.value.toString(); + } + + var response = await InvenTreeAPI().patch( + url, + body: formData, + ); + + if (!response.isValid()) { + // TODO - Display an error message here... + return; + } + + switch (response.statusCode) { + case 200: + case 201: + // Form was validated by the server + Navigator.pop(context); + break; + case 400: + + // Update field errors + for (var field in formFields) { + field.data['errors'] = response.data[field.name]; + + if (field.hasErrors()) { + print("Field '${field.name}' has errors:"); + for (String error in field.errorMessages()) { + print(" - ${error}"); + } + } + } + + break; + } + } + OneContext().showDialog( builder: (BuildContext context) { @@ -209,21 +265,7 @@ Future launchApiForm(String title, String url, Map fields if (_formKey.currentState!.validate()) { _formKey.currentState!.save(); - // Package up the form data - Map formData = {}; - - for (var field in formFields) { - formData[field.name] = field.value.toString(); - } - - print(formData.toString()); - - // Send the data to the server - - // Respond to error message - - // Dismiss the form - + sendRequest(context); } }, ) From 27caf3983aa88fd5a122b1b5a983845d770c380d Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 16 Jul 2021 17:35:56 +1000 Subject: [PATCH 08/26] Add callback functions --- lib/api_form.dart | 11 ++++++++++- lib/widget/part_detail.dart | 3 ++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/api_form.dart b/lib/api_form.dart index 11c764a6..24b4b351 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -137,7 +137,7 @@ Map extractFields(APIResponse response) { * @param method is the HTTP method to use to send the form data to the server (e.g. POST / PATCH) */ -Future launchApiForm(String title, String url, Map fields, {Map modelData = const {}, String method = "PATCH"}) async { +Future launchApiForm(String title, String url, Map fields, {Map modelData = const {}, String method = "PATCH", Function? onSuccess, Function? onCancel}) async { var options = await InvenTreeAPI().options(url); @@ -225,6 +225,11 @@ Future launchApiForm(String title, String url, Map fields case 201: // Form was validated by the server Navigator.pop(context); + + if (onSuccess != null) { + onSuccess(); + } + break; case 400: @@ -255,6 +260,10 @@ Future launchApiForm(String title, String url, Map fields child: Text(L10().cancel), onPressed: () { Navigator.pop(context); + + if (onCancel != null) { + onCancel(); + } }, ), // Save button diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index 88bd0b3c..2f058d9a 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -192,7 +192,8 @@ class _PartDisplayState extends RefreshableState { }, "active": {}, }, - modelData: part.jsondata + modelData: part.jsondata, + onSuccess: refresh, ); return; From ca6226a596327f0bffea684f79ae05e0a00737ba Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 20 Jul 2021 19:14:44 +1000 Subject: [PATCH 09/26] Form updates --- lib/api_form.dart | 116 +++++++++++++++++++++++------------- lib/l10n | 2 +- lib/widget/part_detail.dart | 12 ++-- 3 files changed, 81 insertions(+), 49 deletions(-) diff --git a/lib/api_form.dart b/lib/api_form.dart index 24b4b351..e0cf4578 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -63,6 +63,7 @@ class APIFormField { Widget constructField() { switch (type) { case "string": + case "url": return _constructString(); case "boolean": return _constructBoolean(); @@ -86,8 +87,9 @@ class APIFormField { data["value"] = val; }, validator: (value) { - - // TODO - Custom field validation + if (required && (value == null || value.isEmpty)) { + return L10().valueCannotBeEmpty; + } }, ); } @@ -193,14 +195,39 @@ Future launchApiForm(String title, String url, Map fields formFields.add(APIFormField(fieldName, remoteField)); } - List widgets = []; + List buildWidgets() { + List widgets = []; + + for (var ff in formFields) { + + if (ff.hidden) { + continue; + } - for (var ff in formFields) { - if (!ff.hidden) { widgets.add(ff.constructField()); + + if (ff.hasErrors()) { + for (String error in ff.errorMessages()) { + widgets.add( + ListTile( + title: Text( + error, + style: TextStyle(color: Color.fromRGBO(250, 50, 50, 1)) + ), + ) + ); + } + } + } + + return widgets; } + + List _widgets = buildWidgets(); + + void sendRequest(BuildContext context) async { // Package up the form data @@ -252,44 +279,51 @@ Future launchApiForm(String title, String url, Map fields OneContext().showDialog( builder: (BuildContext context) { - return AlertDialog( - title: Text(title), - actions: [ - // Cancel button - TextButton( - child: Text(L10().cancel), - onPressed: () { - Navigator.pop(context); + return StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + title: Text(title), + actions: [ + // Cancel button + TextButton( + child: Text(L10().cancel), + onPressed: () { + Navigator.pop(context); - if (onCancel != null) { - onCancel(); - } - }, - ), - // Save button - TextButton( - child: Text(L10().save), - onPressed: () { - // Validate the form - if (_formKey.currentState!.validate()) { - _formKey.currentState!.save(); + if (onCancel != null) { + onCancel(); + } + }, + ), + // Save button + TextButton( + child: Text(L10().save), + onPressed: () { + // Validate the form + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); - sendRequest(context); - } - }, - ) - ], - content: Form( - key: _formKey, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: widgets - ) - ) - ) + setState(() { + sendRequest(context); + _widgets = buildWidgets(); + }); + } + }, + ) + ], + content: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: _widgets, + ) + ) + ) + ); + } ); } ); diff --git a/lib/l10n b/lib/l10n index 84f6ed3f..0870256f 160000 --- a/lib/l10n +++ b/lib/l10n @@ -1 +1 @@ -Subproject commit 84f6ed3faf63cbf371016e134196400e5f822759 +Subproject commit 0870256fb97a27ccf0ab73b4665b886ca0abb4aa diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index 2f058d9a..bb01ea0e 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -179,21 +179,19 @@ class _PartDisplayState extends RefreshableState { var _keywords; var _link; - launchApiForm( - "Edit Part", + L10().editPart, part.url, { "name": {}, "description": {}, - "IPN": { - "hidden": true, - "label": "My custom label!", - }, + "IPN": {}, + "keywords": {}, "active": {}, + "link": {}, }, modelData: part.jsondata, - onSuccess: refresh, + onSuccess: refresh, ); return; From 64aed4b31a608c30cf933e3dd4a0b99974058a37 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 22 Jul 2021 17:49:43 +1000 Subject: [PATCH 10/26] Refactor modal form into a new stateful widget --- lib/api_form.dart | 206 +++++++++++++++++++++--------------- lib/widget/home.dart | 1 + lib/widget/part_detail.dart | 7 +- 3 files changed, 126 insertions(+), 88 deletions(-) diff --git a/lib/api_form.dart b/lib/api_form.dart index e0cf4578..514d3752 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -1,3 +1,4 @@ +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:inventree/api.dart'; import 'package:inventree/widget/dialogs.dart'; import 'package:inventree/widget/fields.dart'; @@ -139,7 +140,7 @@ Map extractFields(APIResponse response) { * @param method is the HTTP method to use to send the form data to the server (e.g. POST / PATCH) */ -Future launchApiForm(String title, String url, Map fields, {Map modelData = const {}, String method = "PATCH", Function? onSuccess, Function? onCancel}) async { +Future launchApiForm(BuildContext context, String title, String url, Map fields, {Map modelData = const {}, String method = "PATCH", Function? onSuccess, Function? onCancel}) async { var options = await InvenTreeAPI().options(url); @@ -195,136 +196,169 @@ Future launchApiForm(String title, String url, Map fields formFields.add(APIFormField(fieldName, remoteField)); } - List buildWidgets() { + // Now, launch a new widget! + Navigator.push( + context, + MaterialPageRoute(builder: (context) => APIFormWidget(title, url, formFields)) + ); +} + + +class APIFormWidget extends StatefulWidget { + + //! Form title to display + final String title; + + //! API URL + final String url; + + final List fields; + + APIFormWidget(this.title, this.url, this.fields, {Key? key}) : super(key: key); + + @override + _APIFormWidgetState createState() => _APIFormWidgetState(title, url, fields); + +} + + +class _APIFormWidgetState extends State { + + final _formKey = new GlobalKey(); + + String title; + + String url; + + List fields; + + _APIFormWidgetState(this.title, this.url, this.fields) : super(); + + List _buildForm() { + List widgets = []; - for (var ff in formFields) { + for (var field in fields) { - if (ff.hidden) { + if (field.hidden) { continue; } - widgets.add(ff.constructField()); + widgets.add(field.constructField()); - if (ff.hasErrors()) { - for (String error in ff.errorMessages()) { + if (field.hasErrors()) { + for (String error in field.errorMessages()) { widgets.add( - ListTile( - title: Text( - error, - style: TextStyle(color: Color.fromRGBO(250, 50, 50, 1)) - ), + ListTile( + title: Text( + error, + style: TextStyle(color: Color.fromRGBO(250, 50, 50, 1)), ) + ) ); } } - } + // TODO: Add a "Save" button + // widgets.add(Spacer()); + + /* + widgets.add( + TextButton( + child: Text( + L10().save + ), + onPressed: null, + ) + ); + */ + return widgets; } - - List _widgets = buildWidgets(); - - - void sendRequest(BuildContext context) async { + Future _save(BuildContext context) async { // Package up the form data - Map formData = {}; + Map _data = {}; - for (var field in formFields) { - formData[field.name] = field.value.toString(); + for (var field in fields) { + _data[field.name] = field.value.toString(); } - var response = await InvenTreeAPI().patch( + // TODO: Handle "POST" forms too!! + final response = await InvenTreeAPI().patch( url, - body: formData, + body: _data, ); if (!response.isValid()) { - // TODO - Display an error message here... + // TODO: Display an error message! return; } switch (response.statusCode) { case 200: case 201: - // Form was validated by the server + // Form was successfully validated by the server + + // Hide this form Navigator.pop(context); - if (onSuccess != null) { - onSuccess(); - } + // TODO: Display a snackBar - break; + // TODO: Run custom onSuccess function + return; case 400: + // Form submission / validation error // Update field errors - for (var field in formFields) { + for (var field in fields) { field.data['errors'] = response.data[field.name]; - - if (field.hasErrors()) { - print("Field '${field.name}' has errors:"); - for (String error in field.errorMessages()) { - print(" - ${error}"); - } - } } - break; + // TODO: Other status codes? } + + setState(() { + // Refresh the form + }); + } + @override + Widget build(BuildContext context) { - OneContext().showDialog( - builder: (BuildContext context) { - return StatefulBuilder( - builder: (context, setState) { - return AlertDialog( - title: Text(title), - actions: [ - // Cancel button - TextButton( - child: Text(L10().cancel), - onPressed: () { - Navigator.pop(context); + return Scaffold( + appBar: AppBar( + title: Text(title), + actions: [ + IconButton( + icon: FaIcon(FontAwesomeIcons.save), + onPressed: () { - if (onCancel != null) { - onCancel(); - } - }, - ), - // Save button - TextButton( - child: Text(L10().save), - onPressed: () { - // Validate the form - if (_formKey.currentState!.validate()) { - _formKey.currentState!.save(); + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); - setState(() { - sendRequest(context); - _widgets = buildWidgets(); - }); - } - }, - ) - ], - content: Form( - key: _formKey, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: _widgets, - ) - ) - ) - ); - } - ); - } - ); + _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), + ) + ) + ); + + } } \ No newline at end of file diff --git a/lib/widget/home.dart b/lib/widget/home.dart index 1c5bc55d..f7ce3fba 100644 --- a/lib/widget/home.dart +++ b/lib/widget/home.dart @@ -19,6 +19,7 @@ import 'package:inventree/widget/spinner.dart'; import 'package:inventree/widget/drawer.dart'; class InvenTreeHomePage extends StatefulWidget { + InvenTreeHomePage({Key? key}) : super(key: key); @override diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index bb01ea0e..46187b80 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -60,7 +60,9 @@ class _PartDisplayState extends RefreshableState { IconButton( icon: FaIcon(FontAwesomeIcons.edit), tooltip: L10().edit, - onPressed: _editPartDialog, + onPressed: () { + _editPartDialog(context); + }, ) ); } @@ -170,7 +172,7 @@ class _PartDisplayState extends RefreshableState { ); } - void _editPartDialog() { + void _editPartDialog(BuildContext context) { // Values which can be edited var _name; @@ -180,6 +182,7 @@ class _PartDisplayState extends RefreshableState { var _link; launchApiForm( + context, L10().editPart, part.url, { From 5c2f747b93365bf74537c0c05d7c6a9e2e8d77fe Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 23 Jul 2021 09:42:05 +1000 Subject: [PATCH 11/26] Improve formatting of error messages Also run onSuccess function when form completes --- lib/api_form.dart | 54 +++++++++++++++++++++++++++++++------ lib/widget/part_detail.dart | 53 ------------------------------------ 2 files changed, 46 insertions(+), 61 deletions(-) diff --git a/lib/api_form.dart b/lib/api_form.dart index 514d3752..3e4bc60b 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -60,6 +60,8 @@ class APIFormField { String get helpText => (data['help_text'] ?? '').toString(); + String get placeholderText => (data['placeholder'] ?? '').toString(); + // Construct a widget for this input Widget constructField() { switch (type) { @@ -81,7 +83,17 @@ class APIFormField { return TextFormField( decoration: InputDecoration( labelText: required ? label + "*" : label, - hintText: helpText, + labelStyle: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 22, + color: hasErrors() ? Color.fromRGBO(250, 50, 50, 1) : Color.fromRGBO(50, 50, 50, 1), + ), + helperText: helpText, + helperStyle: TextStyle( + fontStyle: FontStyle.italic, + color: hasErrors() ? Color.fromRGBO(205, 50, 50, 1) : Color.fromRGBO(50, 50, 50, 1), + ), + hintText: placeholderText, ), initialValue: value ?? '', onSaved: (val) { @@ -89,7 +101,7 @@ class APIFormField { }, validator: (value) { if (required && (value == null || value.isEmpty)) { - return L10().valueCannotBeEmpty; + // return L10().valueCannotBeEmpty; } }, ); @@ -199,7 +211,12 @@ Future launchApiForm(BuildContext context, String title, String url, Map APIFormWidget(title, url, formFields)) + MaterialPageRoute(builder: (context) => APIFormWidget( + title, + url, + formFields, + onSuccess: onSuccess, + )) ); } @@ -214,10 +231,20 @@ class APIFormWidget extends StatefulWidget { final List fields; - APIFormWidget(this.title, this.url, this.fields, {Key? key}) : super(key: key); + Function? onSuccess; + + APIFormWidget( + this.title, + this.url, + this.fields, + { + Key? key, + this.onSuccess, + } + ) : super(key: key); @override - _APIFormWidgetState createState() => _APIFormWidgetState(title, url, fields); + _APIFormWidgetState createState() => _APIFormWidgetState(title, url, fields, onSuccess); } @@ -232,7 +259,9 @@ class _APIFormWidgetState extends State { List fields; - _APIFormWidgetState(this.title, this.url, this.fields) : super(); + Function? onSuccess; + + _APIFormWidgetState(this.title, this.url, this.fields, this.onSuccess) : super(); List _buildForm() { @@ -252,7 +281,11 @@ class _APIFormWidgetState extends State { ListTile( title: Text( error, - style: TextStyle(color: Color.fromRGBO(250, 50, 50, 1)), + style: TextStyle( + color: Color.fromRGBO(250, 50, 50, 1), + fontStyle: FontStyle.italic, + fontSize: 16, + ), ) ) ); @@ -307,7 +340,12 @@ class _APIFormWidgetState extends State { // TODO: Display a snackBar - // TODO: Run custom onSuccess function + // Run custom onSuccess function + var successFunc = onSuccess; + + if (successFunc != null) { + successFunc(); + } return; case 400: // Form submission / validation error diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index 46187b80..d717b2a1 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -174,13 +174,6 @@ class _PartDisplayState extends RefreshableState { void _editPartDialog(BuildContext context) { - // Values which can be edited - var _name; - var _description; - var _ipn; - var _keywords; - var _link; - launchApiForm( context, L10().editPart, @@ -196,52 +189,6 @@ class _PartDisplayState extends RefreshableState { modelData: part.jsondata, onSuccess: refresh, ); - - return; - - showFormDialog(L10().editPart, - key: _editPartKey, - callback: () { - _savePart({ - "name": _name, - "description": _description, - "IPN": _ipn, - "keywords": _keywords, - "link": _link - }); - }, - fields: [ - StringField( - label: L10().name, - initial: part.name, - onSaved: (value) => _name = value, - ), - StringField( - label: L10().description, - initial: part.description, - onSaved: (value) => _description = value, - ), - StringField( - label: L10().internalPartNumber, - initial: part.IPN, - allowEmpty: true, - onSaved: (value) => _ipn = value, - ), - StringField( - label: L10().keywords, - initial: part.keywords, - allowEmpty: true, - onSaved: (value) => _keywords = value, - ), - StringField( - label: L10().link, - initial: part.link, - allowEmpty: true, - onSaved: (value) => _link = value - ) - ] - ); - } Widget headerTile() { From 7a6457f870fb0c35852919bd8d1b1a63db5a2b48 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 23 Jul 2021 12:36:03 +1000 Subject: [PATCH 12/26] Improve rendering for checkbox fields --- lib/api_form.dart | 39 ++++++++++++++++++------- lib/widget/fields.dart | 12 ++++++-- lib/widget/stock_item_test_results.dart | 2 +- 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/lib/api_form.dart b/lib/api_form.dart index 3e4bc60b..ed74752f 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -6,6 +6,7 @@ import 'package:inventree/l10.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:inventree/widget/snacks.dart'; import 'package:one_context/one_context.dart'; @@ -83,16 +84,9 @@ class APIFormField { return TextFormField( decoration: InputDecoration( labelText: required ? label + "*" : label, - labelStyle: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 22, - color: hasErrors() ? Color.fromRGBO(250, 50, 50, 1) : Color.fromRGBO(50, 50, 50, 1), - ), + labelStyle: _labelStyle(), helperText: helpText, - helperStyle: TextStyle( - fontStyle: FontStyle.italic, - color: hasErrors() ? Color.fromRGBO(205, 50, 50, 1) : Color.fromRGBO(50, 50, 50, 1), - ), + helperStyle: _helperStyle(), hintText: placeholderText, ), initialValue: value ?? '', @@ -112,13 +106,31 @@ class APIFormField { return CheckBoxField( label: label, - hint: helpText, + labelStyle: _labelStyle(), + helperText: helpText, + helperStyle: _helperStyle(), initial: value, onSaved: (val) { data['value'] = val; }, ); } + + TextStyle _labelStyle() { + return new TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: hasErrors() ? Color.fromRGBO(250, 50, 50, 1) : Color.fromRGBO(50, 50, 50, 1), + ); + } + + TextStyle _helperStyle() { + return new TextStyle( + fontStyle: FontStyle.italic, + color: hasErrors() ? Color.fromRGBO(205, 50, 50, 1) : Color.fromRGBO(50, 50, 50, 1), + ); + } + } @@ -166,7 +178,12 @@ Future launchApiForm(BuildContext context, String title, String url, Map { class CheckBoxField extends FormField { - CheckBoxField({String? label, String? hint, bool initial = false, Function(bool?)? onSaved}) : + CheckBoxField({ + String? label, bool initial = false, Function(bool?)? onSaved, + TextStyle? labelStyle, + String? helperText, + TextStyle? helperStyle, + }) : super( onSaved: onSaved, initialValue: initial, builder: (FormFieldState state) { return CheckboxListTile( //dense: state.hasError, - title: label == null ? null : Text(label), + title: label != null ? Text(label, style: labelStyle) : null, value: state.value, onChanged: state.didChange, - subtitle: hint == null ? null : Text(hint), + subtitle: helperText != null ? Text(helperText, style: helperStyle) : null, + contentPadding: EdgeInsets.zero, ); } ); diff --git a/lib/widget/stock_item_test_results.dart b/lib/widget/stock_item_test_results.dart index 0851224e..66adf0d2 100644 --- a/lib/widget/stock_item_test_results.dart +++ b/lib/widget/stock_item_test_results.dart @@ -84,7 +84,7 @@ class _StockItemTestResultDisplayState extends RefreshableState _result = value ?? false, ), From 1e4e75dfb784a66849182abbfccfcf9fc254d073 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 23 Jul 2021 12:57:56 +1000 Subject: [PATCH 13/26] Refactor colors used in the app --- lib/api_form.dart | 7 ++++--- lib/app_colors.dart | 17 ++++++++++++++++ lib/settings/login.dart | 11 ++++++----- lib/widget/home.dart | 9 +++++---- lib/widget/part_detail.dart | 26 ++++++++++++++++++++++++- lib/widget/spinner.dart | 3 ++- lib/widget/stock_item_test_results.dart | 7 ++++--- 7 files changed, 63 insertions(+), 17 deletions(-) create mode 100644 lib/app_colors.dart diff --git a/lib/api_form.dart b/lib/api_form.dart index ed74752f..786c5e02 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -1,5 +1,6 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:inventree/api.dart'; +import 'package:inventree/app_colors.dart'; import 'package:inventree/widget/dialogs.dart'; import 'package:inventree/widget/fields.dart'; import 'package:inventree/l10.dart'; @@ -120,14 +121,14 @@ class APIFormField { return new TextStyle( fontWeight: FontWeight.bold, fontSize: 18, - color: hasErrors() ? Color.fromRGBO(250, 50, 50, 1) : Color.fromRGBO(50, 50, 50, 1), + color: hasErrors() ? COLOR_DANGER : COLOR_GRAY, ); } TextStyle _helperStyle() { return new TextStyle( fontStyle: FontStyle.italic, - color: hasErrors() ? Color.fromRGBO(205, 50, 50, 1) : Color.fromRGBO(50, 50, 50, 1), + color: hasErrors() ? COLOR_DANGER : COLOR_GRAY, ); } @@ -299,7 +300,7 @@ class _APIFormWidgetState extends State { title: Text( error, style: TextStyle( - color: Color.fromRGBO(250, 50, 50, 1), + color: COLOR_DANGER, fontStyle: FontStyle.italic, fontSize: 16, ), diff --git a/lib/app_colors.dart b/lib/app_colors.dart new file mode 100644 index 00000000..73db5f30 --- /dev/null +++ b/lib/app_colors.dart @@ -0,0 +1,17 @@ + + +import 'dart:ui'; + +const Color COLOR_GRAY = Color.fromRGBO(50, 50, 50, 1); +const Color COLOR_GRAY_LIGHT = Color.fromRGBO(150, 150, 150, 1); + +const Color COLOR_BLUE = Color.fromRGBO(0, 0, 250, 1); + +const Color COLOR_STAR = Color.fromRGBO(250, 250, 100, 1); + +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_SELECTED = Color.fromRGBO(0, 0, 0, 0.05); \ No newline at end of file diff --git a/lib/settings/login.dart b/lib/settings/login.dart index 977e558c..e6a8dd1e 100644 --- a/lib/settings/login.dart +++ b/lib/settings/login.dart @@ -1,3 +1,4 @@ +import 'package:inventree/app_colors.dart'; import 'package:inventree/widget/dialogs.dart'; import 'package:inventree/widget/fields.dart'; import 'package:inventree/widget/spinner.dart'; @@ -219,7 +220,7 @@ class _InvenTreeLoginSettingsState extends State { if ((InvenTreeAPI().profile?.key ?? '') != profile.key) { return FaIcon( FontAwesomeIcons.questionCircle, - color: Color.fromRGBO(250, 150, 50, 1) + color: COLOR_WARNING ); } @@ -227,17 +228,17 @@ class _InvenTreeLoginSettingsState extends State { if (InvenTreeAPI().isConnected()) { return FaIcon( FontAwesomeIcons.checkCircle, - color: Color.fromRGBO(50, 250, 50, 1) + color: COLOR_SUCCESS ); } else if (InvenTreeAPI().isConnecting()) { return Spinner( icon: FontAwesomeIcons.spinner, - color: Color.fromRGBO(50, 50, 250, 1), + color: COLOR_PROGRESS, ); } else { return FaIcon( FontAwesomeIcons.timesCircle, - color: Color.fromRGBO(250, 50, 50, 1), + color: COLOR_DANGER, ); } } @@ -255,7 +256,7 @@ class _InvenTreeLoginSettingsState extends State { title: Text( profile.name, ), - tileColor: profile.selected ? Color.fromRGBO(0, 0, 0, 0.05) : null, + tileColor: profile.selected ? COLOR_SELECTED : null, subtitle: Text("${profile.server}"), trailing: _getProfileIcon(profile), onTap: () { diff --git a/lib/widget/home.dart b/lib/widget/home.dart index f7ce3fba..b62dca09 100644 --- a/lib/widget/home.dart +++ b/lib/widget/home.dart @@ -1,3 +1,4 @@ +import 'package:inventree/app_colors.dart'; import 'package:inventree/user_profile.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -131,7 +132,7 @@ class _InvenTreeHomePageState extends State { leading: FaIcon(FontAwesomeIcons.server), trailing: FaIcon( FontAwesomeIcons.user, - color: Color.fromRGBO(250, 50, 50, 1), + color: COLOR_DANGER, ), onTap: () { _selectProfile(); @@ -147,7 +148,7 @@ class _InvenTreeHomePageState extends State { leading: FaIcon(FontAwesomeIcons.server), trailing: Spinner( icon: FontAwesomeIcons.spinner, - color: Color.fromRGBO(50, 50, 250, 1), + color: COLOR_PROGRESS, ), onTap: () { _selectProfile(); @@ -160,7 +161,7 @@ class _InvenTreeHomePageState extends State { leading: FaIcon(FontAwesomeIcons.server), trailing: FaIcon( FontAwesomeIcons.checkCircle, - color: Color.fromRGBO(50, 250, 50, 1) + color: COLOR_SUCCESS ), onTap: () { _selectProfile(); @@ -173,7 +174,7 @@ class _InvenTreeHomePageState extends State { leading: FaIcon(FontAwesomeIcons.server), trailing: FaIcon( FontAwesomeIcons.timesCircle, - color: Color.fromRGBO(250, 50, 50, 1), + color: COLOR_DANGER, ), onTap: () { _selectProfile(); diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index d717b2a1..f78b258b 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -4,6 +4,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:inventree/app_colors.dart'; import 'package:inventree/l10.dart'; import 'package:inventree/api_form.dart'; @@ -198,7 +199,7 @@ class _PartDisplayState extends RefreshableState { subtitle: Text("${part.description}"), trailing: IconButton( icon: FaIcon(part.starred ? FontAwesomeIcons.solidStar : FontAwesomeIcons.star, - color: part.starred ? Color.fromRGBO(250, 250, 100, 1) : null, + color: part.starred ? COLOR_STAR : null, ), onPressed: _toggleStar, ), @@ -232,6 +233,29 @@ class _PartDisplayState extends RefreshableState { return tiles; } + if (!part.isActive) { + tiles.add( + ListTile( + title: Text( + L10().inactive, + style: TextStyle( + color: COLOR_DANGER + ) + ), + subtitle: Text( + L10().inactiveDetail, + style: TextStyle( + color: COLOR_DANGER + ) + ), + leading: FaIcon( + FontAwesomeIcons.exclamationCircle, + color: COLOR_DANGER + ), + ) + ); + } + // Category information if (part.categoryName.isNotEmpty) { tiles.add( diff --git a/lib/widget/spinner.dart b/lib/widget/spinner.dart index eb049a11..1770a90f 100644 --- a/lib/widget/spinner.dart +++ b/lib/widget/spinner.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:inventree/app_colors.dart'; class Spinner extends StatefulWidget { final IconData? icon; @@ -9,7 +10,7 @@ class Spinner extends StatefulWidget { final Color color; const Spinner({ - this.color = const Color.fromRGBO(150, 150, 150, 1), + this.color = COLOR_GRAY_LIGHT, Key? key, @required this.icon, this.duration = const Duration(milliseconds: 1800), diff --git a/lib/widget/stock_item_test_results.dart b/lib/widget/stock_item_test_results.dart index 66adf0d2..fe85175e 100644 --- a/lib/widget/stock_item_test_results.dart +++ b/lib/widget/stock_item_test_results.dart @@ -1,3 +1,4 @@ +import 'package:inventree/app_colors.dart'; import 'package:inventree/inventree/part.dart'; import 'package:inventree/inventree/stock.dart'; import 'package:inventree/inventree/model.dart'; @@ -207,7 +208,7 @@ class _StockItemTestResultDisplayState extends RefreshableState Date: Fri, 23 Jul 2021 13:07:15 +1000 Subject: [PATCH 14/26] Add more editable fields to the Part --- lib/l10n | 2 +- lib/widget/part_detail.dart | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/l10n b/lib/l10n index af4cd902..73f95460 160000 --- a/lib/l10n +++ b/lib/l10n @@ -1 +1 @@ -Subproject commit af4cd9026a96d44d60f9187119f5ce19c74738d3 +Subproject commit 73f9546016fa6796493462b03e13ae2386157b20 diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index f78b258b..45ad8935 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -183,9 +183,19 @@ class _PartDisplayState extends RefreshableState { "name": {}, "description": {}, "IPN": {}, + "revision": {}, "keywords": {}, - "active": {}, "link": {}, + + // Checkbox fields + "active": {}, + "assembly": {}, + "component": {}, + "purchaseable": {}, + "salable": {}, + "trackable": {}, + "is_template": {}, + "virtual": {}, }, modelData: part.jsondata, onSuccess: refresh, From ca4297ae6de0204532eb58f153497f7d89e6b651 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 25 Jul 2021 23:26:27 +1000 Subject: [PATCH 15/26] Working on implementation of a related field --- lib/api.dart | 8 +++- lib/api_form.dart | 28 +++++++++++++- lib/widget/fields.dart | 77 +++++++++++++++++++++++++++++++++++++ lib/widget/part_detail.dart | 2 + 4 files changed, 113 insertions(+), 2 deletions(-) diff --git a/lib/api.dart b/lib/api.dart index 71f6a5da..3949d61d 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -128,7 +128,13 @@ class InvenTreeAPI { String get imageUrl => _makeUrl("/image/"); - String makeApiUrl(String endpoint) => _makeUrl("/api/" + endpoint); + String makeApiUrl(String endpoint) { + if (endpoint.startsWith("/api/") || endpoint.startsWith("api/")) { + return _makeUrl(endpoint); + } else { + return _makeUrl("/api/" + endpoint); + } + } String makeUrl(String endpoint) => _makeUrl(endpoint); diff --git a/lib/api_form.dart b/lib/api_form.dart index 786c5e02..3c49726e 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -1,3 +1,4 @@ +import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:inventree/api.dart'; import 'package:inventree/app_colors.dart'; @@ -26,6 +27,9 @@ class APIFormField { // JSON data which defines the field final dynamic data; + // Get the "api_url" associated with a related field + String get api_url => data["api_url"] ?? ""; + // Is this field hidden? bool get hidden => (data['hidden'] ?? false) as bool; @@ -53,6 +57,9 @@ class APIFormField { return messages; } + // TODO + dynamic get filters => null; + // Is this field required? bool get required => (data['required'] ?? false) as bool; @@ -72,13 +79,32 @@ class APIFormField { return _constructString(); case "boolean": return _constructBoolean(); + case "related field": + return _constructRelatedField(); default: return ListTile( - title: Text("Unsupported field type: '${type}'") + title: Text( + "Unsupported field type: '${type}'", + style: TextStyle( + color: COLOR_DANGER, + fontStyle: FontStyle.italic), + ) ); } } + // Construct an input for a related field + Widget _constructRelatedField() { + + return AutocompleteFormField( + required ? label + "*" : label, + api_url, + filters: filters, + hint: helpText, + renderer: null, + ); + } + // Consturct a string input element Widget _constructString() { diff --git a/lib/widget/fields.dart b/lib/widget/fields.dart index eb1db803..3db99218 100644 --- a/lib/widget/fields.dart +++ b/lib/widget/fields.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:inventree/api.dart'; import 'package:inventree/l10.dart'; @@ -115,6 +117,81 @@ class CheckBoxField extends FormField { } +class AutocompleteFormField extends TypeAheadFormField { + + final String label; + + final _controller = TextEditingController(); + + final String url; + + dynamic filters = {}; + + AutocompleteFormField( + this.label, + this.url, + { + this.filters, + Widget Function(dynamic)? renderer, + String? hint, + }) : + super( + textFieldConfiguration: TextFieldConfiguration( + autofocus: true, + decoration: InputDecoration( + hintText: hint, + border: OutlineInputBorder(), + ), + ), + suggestionsCallback: (String pattern) async { + + Map _filters = {}; + + if (filters != null) { + for (String key in filters) { + _filters[key] = filters[key].toString(); + } + } + + _filters["search"] = pattern; + _filters["offset"] = "0"; + _filters["limit"] = "25"; + + final APIResponse response = await InvenTreeAPI().get( + url, + params: _filters + ); + + if (response.isValid()) { + + List results = []; + + for (var result in response.data['results'] ?? []) { + results.add(result); + } + + return results; + } else { + return []; + } + + }, + itemBuilder: (context, suggestion) { + print("item builder: " + suggestion.toString()); + return ListTile( + title: Text(suggestion['name']), + ); + }, + onSuggestionSelected: (suggestion) { + // TODO + }, + onSaved: (value) { + // TODO + } + ); +} + + class StringField extends TextFormField { StringField({String label = "", String? hint, String? initial, Function(String?)? onSaved, Function? validator, bool allowEmpty = false, bool isEnabled = true}) : diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index 45ad8935..818dd2bb 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -187,6 +187,8 @@ class _PartDisplayState extends RefreshableState { "keywords": {}, "link": {}, + "category": {}, + // Checkbox fields "active": {}, "assembly": {}, From d07b7040142355d4dff3d2f060f27363ad45b89f Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 26 Jul 2021 17:21:17 +1000 Subject: [PATCH 16/26] Looks like dropdown_search is the way to go! --- assets/release_notes.md | 6 ++++ lib/api.dart | 2 +- lib/api_form.dart | 69 ++++++++++++++++++++++++++++++++----- lib/l10n | 2 +- lib/widget/fields.dart | 76 ----------------------------------------- pubspec.lock | 28 +++++++++++++++ pubspec.yaml | 1 + 7 files changed, 97 insertions(+), 87 deletions(-) diff --git a/assets/release_notes.md b/assets/release_notes.md index 6b7bc7d2..c1a31d5d 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -1,6 +1,12 @@ ## InvenTree App Release Notes --- +### 0.3.0 - July 2021 +--- + + +- Updated translations + ### 0.2.10 - July 2021 --- diff --git a/lib/api.dart b/lib/api.dart index 3949d61d..99eccb82 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -93,7 +93,7 @@ class InvenTreeFileService extends FileService { class InvenTreeAPI { // Minimum required API version for server - static const _minApiVersion = 6; + static const _minApiVersion = 7; // Endpoint for requesting an API token static const _URL_GET_TOKEN = "user/token/"; diff --git a/lib/api_form.dart b/lib/api_form.dart index 3c49726e..87876564 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -1,15 +1,15 @@ -import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:dropdown_search/dropdown_search.dart'; + import 'package:inventree/api.dart'; import 'package:inventree/app_colors.dart'; -import 'package:inventree/widget/dialogs.dart'; import 'package:inventree/widget/fields.dart'; import 'package:inventree/l10.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:inventree/widget/snacks.dart'; -import 'package:one_context/one_context.dart'; + /* @@ -18,6 +18,8 @@ import 'package:one_context/one_context.dart'; */ class APIFormField { + final _controller = TextEditingController(); + // Constructor APIFormField(this.name, this.data); @@ -96,16 +98,65 @@ class APIFormField { // Construct an input for a related field Widget _constructRelatedField() { - return AutocompleteFormField( - required ? label + "*" : label, - api_url, - filters: filters, + return DropdownSearch( + mode: Mode.BOTTOM_SHEET, + showSelectedItem: true, + onFind: (String filter) async { + + Map _filters = {}; + + if (filters != null) { + for (String key in filters) { + _filters[key] = filters[key].toString(); + } + } + + _filters["search"] = filter; + _filters["offset"] = "0"; + _filters["limit"] = "25"; + + final APIResponse response = await InvenTreeAPI().get( + api_url, + params: _filters + ); + + if (response.isValid()) { + + List results = []; + + for (var result in response.data['results'] ?? []) { + results.add(result); + } + + print("Results:"); + print(results); + + return results; + } else { + return []; + } + }, + label: label, hint: helpText, - renderer: null, + onChanged: print, + showClearButton: !required, + itemAsString: (dynamic item) { + return item['pathstring']; + }, + isFilteredOnline: true, + showSearchBox: true, + compareFn: (dynamic item, dynamic selectedItem) { + + if (item == null || selectedItem == null) { + return false; + } + + return item['pk'] == selectedItem['pk']; + } ); } - // Consturct a string input element + // Construct a string input element Widget _constructString() { return TextFormField( diff --git a/lib/l10n b/lib/l10n index 73f95460..94e1193e 160000 --- a/lib/l10n +++ b/lib/l10n @@ -1 +1 @@ -Subproject commit 73f9546016fa6796493462b03e13ae2386157b20 +Subproject commit 94e1193ef17ad536afc92b3b071cb3335e7d828c diff --git a/lib/widget/fields.dart b/lib/widget/fields.dart index 3db99218..3592ea49 100644 --- a/lib/widget/fields.dart +++ b/lib/widget/fields.dart @@ -116,82 +116,6 @@ class CheckBoxField extends FormField { ); } - -class AutocompleteFormField extends TypeAheadFormField { - - final String label; - - final _controller = TextEditingController(); - - final String url; - - dynamic filters = {}; - - AutocompleteFormField( - this.label, - this.url, - { - this.filters, - Widget Function(dynamic)? renderer, - String? hint, - }) : - super( - textFieldConfiguration: TextFieldConfiguration( - autofocus: true, - decoration: InputDecoration( - hintText: hint, - border: OutlineInputBorder(), - ), - ), - suggestionsCallback: (String pattern) async { - - Map _filters = {}; - - if (filters != null) { - for (String key in filters) { - _filters[key] = filters[key].toString(); - } - } - - _filters["search"] = pattern; - _filters["offset"] = "0"; - _filters["limit"] = "25"; - - final APIResponse response = await InvenTreeAPI().get( - url, - params: _filters - ); - - if (response.isValid()) { - - List results = []; - - for (var result in response.data['results'] ?? []) { - results.add(result); - } - - return results; - } else { - return []; - } - - }, - itemBuilder: (context, suggestion) { - print("item builder: " + suggestion.toString()); - return ListTile( - title: Text(suggestion['name']), - ); - }, - onSuggestionSelected: (suggestion) { - // TODO - }, - onSaved: (value) { - // TODO - } - ); -} - - class StringField extends TextFormField { StringField({String label = "", String? hint, String? initial, Function(String?)? onSaved, Function? validator, bool allowEmpty = false, bool isEnabled = true}) : diff --git a/pubspec.lock b/pubspec.lock index 9fd00be7..b06e4e40 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -127,6 +127,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.1" + dropdown_plus: + dependency: "direct main" + description: + name: dropdown_plus + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.9" + dropdown_search: + dependency: "direct main" + description: + name: dropdown_search + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.3" fake_async: dependency: transitive description: @@ -148,6 +162,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "6.1.2" + find_dropdown: + dependency: "direct main" + description: + name: find_dropdown + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" flutter: dependency: "direct main" description: flutter @@ -490,6 +511,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.27.1" + select_dialog: + dependency: transitive + description: + name: select_dialog + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" sembast: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 6bea3c99..c35380db 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: one_context: ^1.1.0 # Dialogs without requiring context infinite_scroll_pagination: ^3.1.0 # Let the server do all the work! audioplayers: ^0.19.0 # Play audio files + dropdown_search: 0.6.3 # Dropdown autocomplete form fields path: dev_dependencies: From 2bdadc21403df91d798e369075fccf2a2a74061b Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 26 Jul 2021 17:28:16 +1000 Subject: [PATCH 17/26] Cleanup --- lib/api_form.dart | 14 +++++++++----- lib/barcode.dart | 3 +-- lib/inventree/model.dart | 4 +--- lib/inventree/stock.dart | 9 +-------- lib/widget/paginator.dart | 11 +++-------- pubspec.lock | 21 --------------------- 6 files changed, 15 insertions(+), 47 deletions(-) diff --git a/lib/api_form.dart b/lib/api_form.dart index 87876564..3c798e9e 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:dropdown_search/dropdown_search.dart'; @@ -128,9 +130,6 @@ class APIFormField { results.add(result); } - print("Results:"); - print(results); - return results; } else { return []; @@ -140,11 +139,16 @@ class APIFormField { hint: helpText, onChanged: print, showClearButton: !required, + // popupTitle: Text( + // label, + // style: _labelStyle(), + // ), itemAsString: (dynamic item) { return item['pathstring']; }, isFilteredOnline: true, showSearchBox: true, + autoFocusSearchBox: true, compareFn: (dynamic item, dynamic selectedItem) { if (item == null || selectedItem == null) { @@ -198,7 +202,9 @@ class APIFormField { return new TextStyle( fontWeight: FontWeight.bold, fontSize: 18, + fontFamily: "arial", color: hasErrors() ? COLOR_DANGER : COLOR_GRAY, + fontStyle: FontStyle.normal, ); } @@ -246,8 +252,6 @@ Future launchApiForm(BuildContext context, String title, String url, Map(); - // Invalid response from server if (!options.isValid()) { return; diff --git a/lib/barcode.dart b/lib/barcode.dart index 7f361e9b..af7d5592 100644 --- a/lib/barcode.dart +++ b/lib/barcode.dart @@ -271,8 +271,7 @@ class StockItemBarcodeAssignmentHandler extends BarcodeHandler { final InvenTreeStockItem item; - StockItemBarcodeAssignmentHandler(this.item) { - } + StockItemBarcodeAssignmentHandler(this.item); @override String getOverlayText(BuildContext context) => L10().barcodeScanAssign; diff --git a/lib/inventree/model.dart b/lib/inventree/model.dart index 90f6d4ce..65e3f2e6 100644 --- a/lib/inventree/model.dart +++ b/lib/inventree/model.dart @@ -296,9 +296,7 @@ class InvenTreeModel { // Create a new object (of the current class type InvenTreeModel obj = createFromJson(d); - if (obj != null) { - results.add(obj); - } + results.add(obj); } return results; diff --git a/lib/inventree/stock.dart b/lib/inventree/stock.dart index 889ed4d4..cb1a524b 100644 --- a/lib/inventree/stock.dart +++ b/lib/inventree/stock.dart @@ -476,14 +476,7 @@ class InvenTreeStockItem extends InvenTreeModel { expectedStatusCode: 200 ); - print("Adjustment completed!"); - - if (response == null) { - return false; - } - - // Stock adjustment succeeded! - return true; + return response.isValid(); } Future countStock(BuildContext context, double q, {String? notes}) async { diff --git a/lib/widget/paginator.dart b/lib/widget/paginator.dart index 6ab9c337..84641330 100644 --- a/lib/widget/paginator.dart +++ b/lib/widget/paginator.dart @@ -21,19 +21,14 @@ class PaginatedSearchWidget extends StatelessWidget { leading: GestureDetector( child: FaIcon(controller.text.isEmpty ? FontAwesomeIcons.search : FontAwesomeIcons.backspace), onTap: () { - if (onChanged != null) { - controller.clear(); - onChanged(); - } + controller.clear(); + onChanged(); }, ), title: TextFormField( controller: controller, onChanged: (value) { - - if (onChanged != null) { - onChanged(); - } + onChanged(); }, decoration: InputDecoration( hintText: L10().search, diff --git a/pubspec.lock b/pubspec.lock index b06e4e40..e7d8e070 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -127,13 +127,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.1" - dropdown_plus: - dependency: "direct main" - description: - name: dropdown_plus - url: "https://pub.dartlang.org" - source: hosted - version: "0.0.9" dropdown_search: dependency: "direct main" description: @@ -162,13 +155,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "6.1.2" - find_dropdown: - dependency: "direct main" - description: - name: find_dropdown - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" flutter: dependency: "direct main" description: flutter @@ -511,13 +497,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.27.1" - select_dialog: - dependency: transitive - description: - name: select_dialog - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" sembast: dependency: "direct main" description: From 377da3c2fbcabe2408501d781ab60dda33cd0b42 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 26 Jul 2021 21:02:24 +1000 Subject: [PATCH 18/26] Handle "filters" for related fields --- lib/api_form.dart | 69 ++++++++++++++++++++++++++++++++----- lib/widget/part_detail.dart | 5 ++- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/lib/api_form.dart b/lib/api_form.dart index 3c798e9e..6805f51a 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -46,6 +46,38 @@ class APIFormField { // Get the "default" as a string dynamic get defaultValue => data['default']; + Map get filters { + + Map _filters = {}; + + // Start with the provided "model" filters + if (data.containsKey("filters")) { + + dynamic f = data["filters"]; + + if (f is Map) { + f.forEach((key, value) { + _filters[key] = value.toString(); + }); + } + } + + // Now, look at the provided "instance_filters" + if (data.containsKey("instance_filters")) { + + dynamic f = data["instance_filters"]; + + if (f is Map) { + f.forEach((key, value) { + _filters[key] = value.toString(); + }); + } + } + + return _filters; + + } + bool hasErrors() => errorMessages().length > 0; // Return the error message associated with this field @@ -61,9 +93,6 @@ class APIFormField { return messages; } - // TODO - dynamic get filters => null; - // Is this field required? bool get required => (data['required'] ?? false) as bool; @@ -107,11 +136,9 @@ class APIFormField { Map _filters = {}; - if (filters != null) { - for (String key in filters) { - _filters[key] = filters[key].toString(); - } - } + filters.forEach((key, value) { + _filters[key] = value; + }); _filters["search"] = filter; _filters["offset"] = "0"; @@ -146,6 +173,19 @@ class APIFormField { itemAsString: (dynamic item) { return item['pathstring']; }, + popupItemBuilder: (context, item, isSelected) { + return ListTile( + title: Text( + item['pathstring'].toString(), + style: TextStyle(fontWeight: isSelected ? FontWeight.bold : FontWeight.normal), + ), + subtitle: Text(item['description'].toString()), + trailing: Text(item['pk'].toString()), + ); + }, + onSaved: (item) { + data['value'] = item['pk'].toString(); + }, isFilteredOnline: true, showSearchBox: true, autoFocusSearchBox: true, @@ -288,6 +328,19 @@ Future launchApiForm(BuildContext context, String title, String url, Map { "keywords": {}, "link": {}, - "category": {}, + "category": { + "filters": { + } + }, // Checkbox fields "active": {}, From bc713dfdcddc9d7f014aca040a0e965fa357905a Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 26 Jul 2021 21:56:29 +1000 Subject: [PATCH 19/26] API form for editing PartCategory - Custom related field renderer function - Grab related field data from the server --- lib/api_form.dart | 133 ++++++++++++++++++++++++------- lib/widget/category_display.dart | 54 ++++--------- lib/widget/part_detail.dart | 2 - 3 files changed, 120 insertions(+), 69 deletions(-) diff --git a/lib/api_form.dart b/lib/api_form.dart index 6805f51a..947229a2 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -5,6 +5,7 @@ import 'package:dropdown_search/dropdown_search.dart'; import 'package:inventree/api.dart'; import 'package:inventree/app_colors.dart'; +import 'package:inventree/inventree/part.dart'; import 'package:inventree/widget/fields.dart'; import 'package:inventree/l10.dart'; @@ -31,9 +32,14 @@ class APIFormField { // JSON data which defines the field final dynamic data; + dynamic initial_data; + // Get the "api_url" associated with a related field String get api_url => data["api_url"] ?? ""; + // Get the "model" associated with a related field + String get model => data["model"] ?? ""; + // Is this field hidden? bool get hidden => (data['hidden'] ?? false) as bool; @@ -104,6 +110,36 @@ class APIFormField { String get placeholderText => (data['placeholder'] ?? '').toString(); + Future 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.isValid()) { + initial_data = response.data; + } + } + // Construct a widget for this input Widget constructField() { switch (type) { @@ -132,6 +168,7 @@ class APIFormField { return DropdownSearch( mode: Mode.BOTTOM_SHEET, showSelectedItem: true, + selectedItem: initial_data, onFind: (String filter) async { Map _filters = {}; @@ -164,32 +201,29 @@ class APIFormField { }, label: label, hint: helpText, - onChanged: print, + onChanged: null, showClearButton: !required, - // popupTitle: Text( - // label, - // style: _labelStyle(), - // ), itemAsString: (dynamic item) { return item['pathstring']; }, + dropdownBuilder: (context, item, itemAsString) { + return _renderRelatedField(item, true, false); + }, popupItemBuilder: (context, item, isSelected) { - return ListTile( - title: Text( - item['pathstring'].toString(), - style: TextStyle(fontWeight: isSelected ? FontWeight.bold : FontWeight.normal), - ), - subtitle: Text(item['description'].toString()), - trailing: Text(item['pk'].toString()), - ); + return _renderRelatedField(item, isSelected, true); }, onSaved: (item) { - data['value'] = item['pk'].toString(); + if (item != null) { + data['value'] = item['pk'] ?? null; + } else { + data['value'] = null; + } }, isFilteredOnline: true, showSearchBox: true, autoFocusSearchBox: true, compareFn: (dynamic item, dynamic selectedItem) { + // Comparison is based on the PK value if (item == null || selectedItem == null) { return false; @@ -200,6 +234,48 @@ class APIFormField { ); } + Widget _renderRelatedField(dynamic item, bool selected, bool extended) { + // Render a "related field" based on the "model" type + + if (item == null) { + return Text( + helpText, + style: TextStyle( + fontStyle: FontStyle.italic + ), + ); + } + + switch (model) { + case "partcategory": + + var cat = InvenTreePartCategory.fromJson(item); + + 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, + ); + default: + return ListTile( + title: Text( + "Unsupported model", + style: TextStyle( + fontWeight: FontWeight.bold, + color: COLOR_DANGER + ) + ), + subtitle: Text("Model '${model}' rendering not supported"), + ); + } + + } + // Construct a string input element Widget _constructString() { @@ -341,7 +417,6 @@ Future launchApiForm(BuildContext context, String title, String url, Map launchApiForm(BuildContext context, String title, String url, Map { } } - // TODO: Add a "Save" button - // widgets.add(Spacer()); - - /* - widgets.add( - TextButton( - child: Text( - L10().save - ), - onPressed: null, - ) - ); - */ - return widgets; } @@ -468,7 +534,14 @@ class _APIFormWidgetState extends State { Map _data = {}; for (var field in fields) { - _data[field.name] = field.value.toString(); + + dynamic value = field.value; + + if (value == null) { + _data[field.name] = ""; + } else { + _data[field.name] = value.toString(); + } } // TODO: Handle "POST" forms too!! diff --git a/lib/widget/category_display.dart b/lib/widget/category_display.dart index 62ac1ad7..4388cdd0 100644 --- a/lib/widget/category_display.dart +++ b/lib/widget/category_display.dart @@ -22,6 +22,8 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import '../api_form.dart'; + class CategoryDisplayWidget extends StatefulWidget { CategoryDisplayWidget(this.category, {Key? key}) : super(key: key); @@ -35,7 +37,6 @@ class CategoryDisplayWidget extends StatefulWidget { class _CategoryDisplayState extends RefreshableState { - final _editCategoryKey = GlobalKey(); @override String getAppBarTitle(BuildContext context) => L10().partCategory; @@ -71,7 +72,9 @@ class _CategoryDisplayState extends RefreshableState { IconButton( icon: FaIcon(FontAwesomeIcons.edit), tooltip: L10().edit, - onPressed: _editCategoryDialog, + onPressed: () { + _editCategoryDialog(context); + }, ) ); } @@ -80,49 +83,26 @@ class _CategoryDisplayState extends RefreshableState { } - void _editCategory(Map values) async { + void _editCategoryDialog(BuildContext context) { - final bool result = await category!.update(values: values); - - showSnackIcon( - result ? "Category edited" : "Category editing failed", - success: result - ); - - refresh(); - } - - void _editCategoryDialog() { + final _cat = category; // Cannot edit top-level category - if (category == null) { + if (_cat == null) { return; } - var _name; - var _description; - - showFormDialog( + launchApiForm( + context, L10().editCategory, - key: _editCategoryKey, - callback: () { - _editCategory({ - "name": _name, - "description": _description - }); + _cat.url, + { + "name": {}, + "description": {}, + "parent": {}, }, - fields: [ - StringField( - label: L10().name, - initial: category?.name, - onSaved: (value) => _name = value - ), - StringField( - label: L10().description, - initial: category?.description, - onSaved: (value) => _description = value - ) - ] + modelData: _cat.jsondata, + onSuccess: refresh, ); } diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index 9aadcd79..032cdb2e 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -188,8 +188,6 @@ class _PartDisplayState extends RefreshableState { "link": {}, "category": { - "filters": { - } }, // Checkbox fields From e8cb002e3c3c9152e28267fa66546db03846a386 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 26 Jul 2021 22:03:37 +1000 Subject: [PATCH 20/26] Edit StockLocation --- lib/api_form.dart | 15 +++++++++ lib/widget/location_display.dart | 53 +++++++++++--------------------- 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/lib/api_form.dart b/lib/api_form.dart index 947229a2..f9705f9f 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -6,6 +6,7 @@ import 'package:dropdown_search/dropdown_search.dart'; import 'package:inventree/api.dart'; import 'package:inventree/app_colors.dart'; import 'package:inventree/inventree/part.dart'; +import 'package:inventree/inventree/stock.dart'; import 'package:inventree/widget/fields.dart'; import 'package:inventree/l10.dart'; @@ -261,6 +262,20 @@ class APIFormField { style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal), ) : null, ); + case "stocklocation": + + var loc = InvenTreeStockLocation.fromJson(item); + + 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, + ); default: return ListTile( title: Text( diff --git a/lib/widget/location_display.dart b/lib/widget/location_display.dart index a6313382..b1ffffef 100644 --- a/lib/widget/location_display.dart +++ b/lib/widget/location_display.dart @@ -1,4 +1,5 @@ import 'package:inventree/api.dart'; +import 'package:inventree/api_form.dart'; import 'package:inventree/app_settings.dart'; import 'package:inventree/barcode.dart'; import 'package:inventree/inventree/sentry.dart'; @@ -71,7 +72,7 @@ class _LocationDisplayState extends RefreshableState { IconButton( icon: FaIcon(FontAwesomeIcons.edit), tooltip: L10().edit, - onPressed: _editLocationDialog, + onPressed: () { _editLocationDialog(context); }, ) ); } @@ -79,23 +80,27 @@ class _LocationDisplayState extends RefreshableState { return actions; } - void _editLocation(Map values) async { + void _editLocationDialog(BuildContext context) { - bool result = false; + final _loc = location; - if (location != null) { - result = await location!.update(values: values); - - showSnackIcon( - result ? "Location edited" : "Location editing failed", - success: result - ); + if (_loc == null) { + return; } - refresh(); - } + launchApiForm( + context, + L10().editLocation, + _loc.url, + { + "name": {}, + "description": {}, + "parent": {}, + }, + modelData: _loc.jsondata, + onSuccess: refresh + ); - void _editLocationDialog() { // Values which an be edited var _name; var _description; @@ -103,28 +108,6 @@ class _LocationDisplayState extends RefreshableState { if (location == null) { return; } - - showFormDialog(L10().editLocation, - key: _editLocationKey, - callback: () { - _editLocation({ - "name": _name, - "description": _description - }); - }, - fields: [ - StringField( - label: L10().name, - initial: location?.name ?? '', - onSaved: (value) => _name = value, - ), - StringField( - label: L10().description, - initial: location?.description ?? '', - onSaved: (value) => _description = value, - ) - ] - ); } _LocationDisplayState(this.location); From 978cefd6bfa8b29714c05da8ff7631dad8c16ca3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 26 Jul 2021 22:26:56 +1000 Subject: [PATCH 21/26] edit stock item - Render choice fields --- lib/api.dart | 5 ++-- lib/api_form.dart | 38 ++++++++++++++++++++++++ lib/inventree/model.dart | 2 -- lib/l10n | 2 +- lib/settings/login.dart | 2 +- lib/widget/stock_detail.dart | 57 +++++++++++++++++++++++++++--------- 6 files changed, 86 insertions(+), 20 deletions(-) diff --git a/lib/api.dart b/lib/api.dart index 99eccb82..843cfeb4 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -599,8 +599,6 @@ class InvenTreeAPI { Uri? _uri = Uri.tryParse(_url); - print("apiRequest ${method} -> ${url}"); - if (_uri == null) { showServerError(L10().invalidHost, L10().invalidHostDetails); return null; @@ -627,12 +625,15 @@ class InvenTreeAPI { return _request; } on SocketException catch (error) { + print("SocketException at ${url}: ${error.toString()}"); showServerError(L10().connectionRefused, error.toString()); return null; } on TimeoutException { + print("TimeoutException at ${url}"); showTimeoutError(); return null; } catch (error, stackTrace) { + print("Server error at ${url}: ${error.toString()}"); showServerError(L10().serverError, error.toString()); sentryReportError(error, stackTrace); return null; diff --git a/lib/api_form.dart b/lib/api_form.dart index f9705f9f..6b59f927 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -111,6 +111,8 @@ class APIFormField { String get placeholderText => (data['placeholder'] ?? '').toString(); + List get choices => data["choices"] ?? []; + Future loadInitialData() async { // Only for "related fields" @@ -151,6 +153,8 @@ class APIFormField { return _constructBoolean(); case "related field": return _constructRelatedField(); + case "choice": + return _constructChoiceField(); default: return ListTile( title: Text( @@ -163,6 +167,40 @@ class APIFormField { } } + 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( + mode: Mode.BOTTOM_SHEET, + showSelectedItem: false, + selectedItem: _initial, + items: choices, + label: label, + hint: helpText, + onChanged: null, + showClearButton: !required, + itemAsString: (dynamic item) { + return item['display_name']; + }, + onSaved: (item) { + if (item == null) { + data['value'] = null; + } else { + data['value'] = item['value']; + } + } + ); + } + // Construct an input for a related field Widget _constructRelatedField() { diff --git a/lib/inventree/model.dart b/lib/inventree/model.dart index 65e3f2e6..f7e53adb 100644 --- a/lib/inventree/model.dart +++ b/lib/inventree/model.dart @@ -276,8 +276,6 @@ class InvenTreeModel { params[key] = filters[key] ?? ''; } - print("LIST: $URL ${params.toString()}"); - var response = await api.get(URL, params: params); // A list of "InvenTreeModel" items diff --git a/lib/l10n b/lib/l10n index 94e1193e..4dccf7c3 160000 --- a/lib/l10n +++ b/lib/l10n @@ -1 +1 @@ -Subproject commit 94e1193ef17ad536afc92b3b071cb3335e7d828c +Subproject commit 4dccf7c37fe5bca94fe0ef9ab05f7d5f0ec52452 diff --git a/lib/settings/login.dart b/lib/settings/login.dart index e6a8dd1e..5112161a 100644 --- a/lib/settings/login.dart +++ b/lib/settings/login.dart @@ -56,7 +56,7 @@ class _InvenTreeLoginSettingsState extends State { key: _addProfileKey, callback: () { if (createNew) { - // TODO - create the new profile... + UserProfile profile = UserProfile( name: _name, server: _server, diff --git a/lib/widget/stock_detail.dart b/lib/widget/stock_detail.dart index 1029b021..009c94cc 100644 --- a/lib/widget/stock_detail.dart +++ b/lib/widget/stock_detail.dart @@ -20,6 +20,8 @@ import 'package:inventree/api.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import '../api_form.dart'; + class StockDetailWidget extends StatefulWidget { StockDetailWidget(this.item, {Key? key}) : super(key: key); @@ -49,20 +51,29 @@ class _StockItemDisplayState extends RefreshableState { @override List getAppBarActions(BuildContext context) { - return [ - IconButton( - icon: FaIcon(FontAwesomeIcons.globe), - onPressed: _openInvenTreePage, - ), - // TODO: Hide the 'edit' button if the user does not have permission!! - /* - IconButton( - icon: FaIcon(FontAwesomeIcons.edit), - tooltip: L10().edit, - onPressed: _editPartDialog, - ) - */ - ]; + + List actions = []; + + if (InvenTreeAPI().checkPermission('stock', 'view')) { + actions.add( + IconButton( + icon: FaIcon(FontAwesomeIcons.globe), + onPressed: _openInvenTreePage, + ) + ); + } + + if (InvenTreeAPI().checkPermission('stock', 'change')) { + actions.add( + IconButton( + icon: FaIcon(FontAwesomeIcons.edit), + tooltip: L10().edit, + onPressed: () { _editStockItem(context); }, + ) + ); + } + + return actions; } Future _openInvenTreePage() async { @@ -95,6 +106,24 @@ class _StockItemDisplayState extends RefreshableState { await item.getTestResults(); } + void _editStockItem(BuildContext context) async { + + launchApiForm( + context, + L10().editItem, + item.url, + { + "status": {}, + "batch": {}, + "packaging": {}, + "link": {}, + }, + modelData: item.jsondata, + onSuccess: refresh + ); + + } + void _addStock() async { double quantity = double.parse(_quantityController.text); From 08eb6fc4b12b5590a530fb99b2a29207ecdc87d3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 26 Jul 2021 23:04:43 +1000 Subject: [PATCH 22/26] Remove typeahead library --- lib/api_form.dart | 1 + lib/inventree/model.dart | 14 +++-- lib/widget/fields.dart | 3 - lib/widget/stock_detail.dart | 109 +++++++++++++++++++---------------- pubspec.lock | 28 --------- pubspec.yaml | 1 - 6 files changed, 71 insertions(+), 85 deletions(-) diff --git a/lib/api_form.dart b/lib/api_form.dart index 6b59f927..750aea6c 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -187,6 +187,7 @@ class APIFormField { label: label, hint: helpText, onChanged: null, + autoFocusSearchBox: true, showClearButton: !required, itemAsString: (dynamic item) { return item['display_name']; diff --git a/lib/inventree/model.dart b/lib/inventree/model.dart index f7e53adb..62d2fae0 100644 --- a/lib/inventree/model.dart +++ b/lib/inventree/model.dart @@ -285,11 +285,17 @@ class InvenTreeModel { return results; } - // TODO - handle possible error cases: - // - No data receieved - // - Data is not a list of maps + dynamic data; - for (var d in response.data) { + if (response.data is List) { + data = response.data; + } else if (response.data.containsKey('results')) { + data = response.data['results']; + } else { + data = []; + } + + for (var d in data) { // Create a new object (of the current class type InvenTreeModel obj = createFromJson(d); diff --git a/lib/widget/fields.dart b/lib/widget/fields.dart index 3592ea49..75abe85e 100644 --- a/lib/widget/fields.dart +++ b/lib/widget/fields.dart @@ -1,9 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:inventree/api.dart'; - import 'package:inventree/l10.dart'; import 'dart:async'; diff --git a/lib/widget/stock_detail.dart b/lib/widget/stock_detail.dart index 009c94cc..d04bfb3f 100644 --- a/lib/widget/stock_detail.dart +++ b/lib/widget/stock_detail.dart @@ -1,4 +1,5 @@ import 'package:inventree/barcode.dart'; +import 'package:inventree/inventree/model.dart'; import 'package:inventree/inventree/stock.dart'; import 'package:inventree/inventree/part.dart'; import 'package:inventree/widget/dialogs.dart'; @@ -17,7 +18,7 @@ import 'package:inventree/l10.dart'; import 'package:inventree/api.dart'; -import 'package:flutter_typeahead/flutter_typeahead.dart'; +import 'package:dropdown_search/dropdown_search.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import '../api_form.dart'; @@ -270,7 +271,7 @@ class _StockItemDisplayState extends RefreshableState { } - void _transferStock(InvenTreeStockLocation location) async { + void _transferStock(int locationId) async { double quantity = double.tryParse(_quantityController.text) ?? item.quantity; String notes = _notesController.text; @@ -278,7 +279,7 @@ class _StockItemDisplayState extends RefreshableState { _quantityController.clear(); _notesController.clear(); - var result = await item.transferStock(location.pk, quantity: quantity, notes: notes); + var result = await item.transferStock(locationId, quantity: quantity, notes: notes); refresh(); @@ -287,22 +288,22 @@ class _StockItemDisplayState extends RefreshableState { } } - void _transferStockDialog() async { + void _transferStockDialog(BuildContext context) async { var locations = await InvenTreeStockLocation().list(); final _selectedController = TextEditingController(); - InvenTreeStockLocation? selectedLocation; + int? location_pk; _quantityController.text = "${item.quantityString}"; showFormDialog(L10().transferStock, key: _moveStockKey, callback: () { - var _loc = selectedLocation; + var _pk = location_pk; - if (_loc != null) { - _transferStock(_loc); + if (_pk != null) { + _transferStock(_pk); } }, fields: [ @@ -311,47 +312,57 @@ class _StockItemDisplayState extends RefreshableState { controller: _quantityController, max: item.quantity, ), - TypeAheadFormField( - textFieldConfiguration: TextFieldConfiguration( - controller: _selectedController, - autofocus: true, - decoration: InputDecoration( - hintText: L10().searchLocation, - border: OutlineInputBorder() - ) - ), - suggestionsCallback: (pattern) async { - List suggestions = []; + DropdownSearch( + mode: Mode.BOTTOM_SHEET, + showSelectedItem: false, + autoFocusSearchBox: true, + selectedItem: null, + errorBuilder: (context, entry, exception) { + print("entry: $entry"); + print(exception.toString()); - for (var loc in locations) { - if (loc.matchAgainstString(pattern)) { - suggestions.add(loc as InvenTreeStockLocation); - } + return Text( + exception.toString(), + style: TextStyle( + fontSize: 10, + ) + ); + }, + onFind: (String filter) async { + + Map _filters = { + "search": filter, + "offset": "0", + "limit": "25" + }; + + final List results = await InvenTreeStockLocation().list(filters: _filters); + + List items = []; + + for (InvenTreeModel loc in results) { + if (loc is InvenTreeStockLocation) { + items.add(loc.jsondata); } - - return suggestions; - }, - validator: (value) { - if (selectedLocation == null) { - return L10().selectLocation; - } - - return null; - }, - onSuggestionSelected: (suggestion) { - selectedLocation = suggestion as InvenTreeStockLocation; - _selectedController.text = selectedLocation!.pathstring; - }, - onSaved: (value) { - }, - itemBuilder: (context, suggestion) { - var location = suggestion as InvenTreeStockLocation; - - return ListTile( - title: Text("${location.pathstring}"), - subtitle: Text("${location.description}"), - ); } + + return items; + }, + label: L10().stockLocation, + hint: L10().searchLocation, + onChanged: null, + itemAsString: (dynamic location) { + return location['pathstring']; + }, + onSaved: (dynamic location) { + if (location == null) { + location_pk = null; + } else { + location_pk = location['pk']; + } + }, + isFilteredOnline: true, + showSearchBox: true, ), ], ); @@ -556,7 +567,7 @@ class _StockItemDisplayState extends RefreshableState { return tiles; } - List actionTiles() { + List actionTiles(BuildContext context) { List tiles = []; tiles.add(headerTile()); @@ -610,7 +621,7 @@ class _StockItemDisplayState extends RefreshableState { ListTile( title: Text(L10().transferStock), leading: FaIcon(FontAwesomeIcons.exchangeAlt), - onTap: _transferStockDialog, + onTap: () { _transferStockDialog(context); }, ) ); @@ -694,7 +705,7 @@ class _StockItemDisplayState extends RefreshableState { return ListView( children: ListTile.divideTiles( context: context, - tiles: actionTiles() + tiles: actionTiles(context) ).toList() ); default: diff --git a/pubspec.lock b/pubspec.lock index e7d8e070..e78def2d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -174,27 +174,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.1.2" - flutter_keyboard_visibility: - dependency: transitive - description: - name: flutter_keyboard_visibility - url: "https://pub.dartlang.org" - source: hosted - version: "5.0.2" - flutter_keyboard_visibility_platform_interface: - dependency: transitive - description: - name: flutter_keyboard_visibility_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - flutter_keyboard_visibility_web: - dependency: transitive - description: - name: flutter_keyboard_visibility_web - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -233,13 +212,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_typeahead: - dependency: "direct main" - description: - name: flutter_typeahead - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.3" flutter_web_plugins: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index c35380db..17ccafaf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -30,7 +30,6 @@ dependencies: font_awesome_flutter: ^9.1.0 # FontAwesome icon set flutter_speed_dial: ^3.0.5 # FAB menu elements sentry_flutter: 5.0.0 # Error reporting - flutter_typeahead: ^3.1.0 # Auto-complete input field image_picker: ^0.8.0 # Select or take photos url_launcher: 6.0.0 # Open link in system browser flutter_markdown: ^0.6.2 # Rendering markdown From 8a114d345f4681305b888beaa33a9b1305c22fe4 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 26 Jul 2021 23:18:52 +1000 Subject: [PATCH 23/26] Display "action" items with color --- assets/release_notes.md | 6 +++++- lib/app_colors.dart | 2 ++ lib/widget/category_display.dart | 6 +++++- lib/widget/location_display.dart | 5 +++-- lib/widget/part_detail.dart | 11 +++++------ lib/widget/stock_detail.dart | 32 ++++++++++++++++++-------------- 6 files changed, 38 insertions(+), 24 deletions(-) diff --git a/assets/release_notes.md b/assets/release_notes.md index c1a31d5d..a8a0f6b6 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -4,7 +4,11 @@ ### 0.3.0 - July 2021 --- - +- Adds new "API driven" forms +- Improvements for Part editing form +- Improvements for PartCategory editing form +- Improvements for StockLocation editing form +- Adds ability to edit StockItem - Updated translations ### 0.2.10 - July 2021 diff --git a/lib/app_colors.dart b/lib/app_colors.dart index 73db5f30..a4797c6a 100644 --- a/lib/app_colors.dart +++ b/lib/app_colors.dart @@ -5,6 +5,8 @@ import 'dart:ui'; const Color COLOR_GRAY = Color.fromRGBO(50, 50, 50, 1); const Color COLOR_GRAY_LIGHT = Color.fromRGBO(150, 150, 150, 1); +const Color COLOR_CLICK = Color.fromRGBO(175, 150, 100, 0.9); + const Color COLOR_BLUE = Color.fromRGBO(0, 0, 250, 1); const Color COLOR_STAR = Color.fromRGBO(250, 250, 100, 1); diff --git a/lib/widget/category_display.dart b/lib/widget/category_display.dart index 4388cdd0..87433f13 100644 --- a/lib/widget/category_display.dart +++ b/lib/widget/category_display.dart @@ -1,5 +1,6 @@ import 'package:inventree/api.dart'; +import 'package:inventree/app_colors.dart'; import 'package:inventree/app_settings.dart'; import 'package:inventree/inventree/part.dart'; import 'package:inventree/inventree/sentry.dart'; @@ -166,7 +167,10 @@ class _CategoryDisplayState extends RefreshableState { ListTile( title: Text(L10().parentCategory), subtitle: Text("${category?.parentpathstring}"), - leading: FaIcon(FontAwesomeIcons.levelUpAlt), + leading: FaIcon( + FontAwesomeIcons.levelUpAlt, + color: COLOR_CLICK, + ), onTap: () { if (category == null || ((category?.parentId ?? 0) < 0)) { Navigator.push(context, MaterialPageRoute(builder: (context) => CategoryDisplayWidget(null))); diff --git a/lib/widget/location_display.dart b/lib/widget/location_display.dart index b1ffffef..93bacd2e 100644 --- a/lib/widget/location_display.dart +++ b/lib/widget/location_display.dart @@ -1,5 +1,6 @@ import 'package:inventree/api.dart'; import 'package:inventree/api_form.dart'; +import 'package:inventree/app_colors.dart'; import 'package:inventree/app_settings.dart'; import 'package:inventree/barcode.dart'; import 'package:inventree/inventree/sentry.dart'; @@ -176,7 +177,7 @@ class _LocationDisplayState extends RefreshableState { ListTile( title: Text(L10().parentCategory), subtitle: Text("${location!.parentpathstring}"), - leading: FaIcon(FontAwesomeIcons.levelUpAlt), + leading: FaIcon(FontAwesomeIcons.levelUpAlt, color: COLOR_CLICK), onTap: () { int parent = location?.parentId ?? -1; @@ -302,7 +303,7 @@ List detailTiles() { tiles.add( ListTile( title: Text(L10().barcodeScanInItems), - leading: FaIcon(FontAwesomeIcons.exchangeAlt), + leading: FaIcon(FontAwesomeIcons.exchangeAlt, color: COLOR_CLICK), trailing: FaIcon(FontAwesomeIcons.qrcode), onTap: () { diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index 032cdb2e..9fb5037a 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -275,7 +275,7 @@ class _PartDisplayState extends RefreshableState { ListTile( title: Text(L10().partCategory), subtitle: Text("${part.categoryName}"), - leading: FaIcon(FontAwesomeIcons.sitemap), + leading: FaIcon(FontAwesomeIcons.sitemap, color: COLOR_CLICK), onTap: () { if (part.categoryId > 0) { InvenTreePartCategory().get(part.categoryId).then((var cat) { @@ -294,7 +294,7 @@ class _PartDisplayState extends RefreshableState { ListTile( title: Text(L10().partCategory), subtitle: Text(L10().partCategoryTopLevel), - leading: FaIcon(FontAwesomeIcons.sitemap), + leading: FaIcon(FontAwesomeIcons.sitemap, color: COLOR_CLICK), onTap: () { Navigator.push(context, MaterialPageRoute(builder: (context) => CategoryDisplayWidget(null))); }, @@ -306,7 +306,7 @@ class _PartDisplayState extends RefreshableState { tiles.add( ListTile( title: Text(L10().stock), - leading: FaIcon(FontAwesomeIcons.boxes), + leading: FaIcon(FontAwesomeIcons.boxes, color: COLOR_CLICK), trailing: Text("${part.inStockString}"), onTap: () { setState(() { @@ -392,8 +392,7 @@ class _PartDisplayState extends RefreshableState { tiles.add( ListTile( title: Text("${part.link}"), - leading: FaIcon(FontAwesomeIcons.link), - trailing: FaIcon(FontAwesomeIcons.externalLinkAlt), + leading: FaIcon(FontAwesomeIcons.link, color: COLOR_CLICK), onTap: () { part.openLink(); }, @@ -417,7 +416,7 @@ class _PartDisplayState extends RefreshableState { tiles.add( ListTile( title: Text(L10().notes), - leading: FaIcon(FontAwesomeIcons.stickyNote), + leading: FaIcon(FontAwesomeIcons.stickyNote, color: COLOR_CLICK), trailing: Text(""), onTap: () { Navigator.push( diff --git a/lib/widget/stock_detail.dart b/lib/widget/stock_detail.dart index d04bfb3f..9d1e0ac0 100644 --- a/lib/widget/stock_detail.dart +++ b/lib/widget/stock_detail.dart @@ -1,3 +1,4 @@ +import 'package:inventree/app_colors.dart'; import 'package:inventree/barcode.dart'; import 'package:inventree/inventree/model.dart'; import 'package:inventree/inventree/stock.dart'; @@ -434,7 +435,10 @@ class _StockItemDisplayState extends RefreshableState { ListTile( title: Text(L10().stockLocation), subtitle: Text("${item.locationPathString}"), - leading: FaIcon(FontAwesomeIcons.mapMarkerAlt), + leading: FaIcon( + FontAwesomeIcons.mapMarkerAlt, + color: COLOR_CLICK, + ), onTap: () { if (item.locationId > 0) { InvenTreeStockLocation().get(item.locationId).then((var loc) { @@ -503,9 +507,10 @@ class _StockItemDisplayState extends RefreshableState { tiles.add( ListTile( title: Text("${item.link}"), - leading: FaIcon(FontAwesomeIcons.link), - trailing: Text(""), - onTap: null, + leading: FaIcon(FontAwesomeIcons.link, color: COLOR_CLICK), + onTap: () { + item.openLink(); + }, ) ); } @@ -514,7 +519,7 @@ class _StockItemDisplayState extends RefreshableState { tiles.add( ListTile( title: Text(L10().testResults), - leading: FaIcon(FontAwesomeIcons.tasks), + leading: FaIcon(FontAwesomeIcons.tasks, color: COLOR_CLICK), trailing: Text("${item.testResultCount}"), onTap: () { Navigator.push( @@ -550,8 +555,7 @@ class _StockItemDisplayState extends RefreshableState { tiles.add( ListTile( title: Text(L10().notes), - leading: FaIcon(FontAwesomeIcons.stickyNote), - trailing: Text(""), + leading: FaIcon(FontAwesomeIcons.stickyNote, color: COLOR_CLICK), onTap: () { Navigator.push( context, @@ -594,7 +598,7 @@ class _StockItemDisplayState extends RefreshableState { tiles.add( ListTile( title: Text(L10().countStock), - leading: FaIcon(FontAwesomeIcons.checkCircle), + leading: FaIcon(FontAwesomeIcons.checkCircle, color: COLOR_CLICK), onTap: _countStockDialog, trailing: Text(item.quantityString), ) @@ -603,7 +607,7 @@ class _StockItemDisplayState extends RefreshableState { tiles.add( ListTile( title: Text(L10().removeStock), - leading: FaIcon(FontAwesomeIcons.minusCircle), + leading: FaIcon(FontAwesomeIcons.minusCircle, color: COLOR_CLICK), onTap: _removeStockDialog, ) ); @@ -611,7 +615,7 @@ class _StockItemDisplayState extends RefreshableState { tiles.add( ListTile( title: Text(L10().addStock), - leading: FaIcon(FontAwesomeIcons.plusCircle), + leading: FaIcon(FontAwesomeIcons.plusCircle, color: COLOR_CLICK), onTap: _addStockDialog, ) ); @@ -620,7 +624,7 @@ class _StockItemDisplayState extends RefreshableState { tiles.add( ListTile( title: Text(L10().transferStock), - leading: FaIcon(FontAwesomeIcons.exchangeAlt), + leading: FaIcon(FontAwesomeIcons.exchangeAlt, color: COLOR_CLICK), onTap: () { _transferStockDialog(context); }, ) ); @@ -629,7 +633,7 @@ class _StockItemDisplayState extends RefreshableState { tiles.add( ListTile( title: Text(L10().scanIntoLocation), - leading: FaIcon(FontAwesomeIcons.exchangeAlt), + leading: FaIcon(FontAwesomeIcons.exchangeAlt, color: COLOR_CLICK), trailing: FaIcon(FontAwesomeIcons.qrcode), onTap: () { Navigator.push( @@ -647,7 +651,7 @@ class _StockItemDisplayState extends RefreshableState { tiles.add( ListTile( title: Text(L10().barcodeAssign), - leading: FaIcon(FontAwesomeIcons.barcode), + leading: FaIcon(FontAwesomeIcons.barcode, color: COLOR_CLICK), trailing: FaIcon(FontAwesomeIcons.qrcode), onTap: () { Navigator.push( @@ -663,7 +667,7 @@ class _StockItemDisplayState extends RefreshableState { tiles.add( ListTile( title: Text(L10().barcodeUnassign), - leading: FaIcon(FontAwesomeIcons.barcode), + leading: FaIcon(FontAwesomeIcons.barcode, color: COLOR_CLICK), onTap: () { _unassignBarcode(context); } From fd821bbfc5b2d1fcf2a77eca42ec27171a1c9a0e Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 26 Jul 2021 23:31:36 +1000 Subject: [PATCH 24/26] Display purcahse price for stock item --- assets/release_notes.md | 1 + lib/inventree/stock.dart | 11 +++++++++++ lib/l10n | 2 +- lib/widget/stock_detail.dart | 12 ++++++++++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/assets/release_notes.md b/assets/release_notes.md index a8a0f6b6..19b51b99 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -9,6 +9,7 @@ - Improvements for PartCategory editing form - Improvements for StockLocation editing form - Adds ability to edit StockItem +- Display purchase price (where available) for StockItem - Updated translations ### 0.2.10 - July 2021 diff --git a/lib/inventree/stock.dart b/lib/inventree/stock.dart index cb1a524b..efe48546 100644 --- a/lib/inventree/stock.dart +++ b/lib/inventree/stock.dart @@ -60,6 +60,8 @@ class InvenTreeStockItem extends InvenTreeModel { String statusLabel(BuildContext context) { + // TODO: Delete me - The translated status values are provided by the API! + switch (status) { case OK: return L10().ok; @@ -220,6 +222,15 @@ class InvenTreeStockItem extends InvenTreeModel { int get partId => jsondata['part'] ?? -1; + String get purchasePrice => jsondata['purchase_price']; + + bool get hasPurchasePrice { + + String pp = purchasePrice; + + return pp.isNotEmpty && pp.trim() != "-"; + } + int get trackingItemCount => (jsondata['tracking_items'] ?? 0) as int; // Date of last update diff --git a/lib/l10n b/lib/l10n index 4dccf7c3..46d08c9c 160000 --- a/lib/l10n +++ b/lib/l10n @@ -1 +1 @@ -Subproject commit 4dccf7c37fe5bca94fe0ef9ab05f7d5f0ec52452 +Subproject commit 46d08c9cc0043113fee5c0d134861c5d12554b71 diff --git a/lib/widget/stock_detail.dart b/lib/widget/stock_detail.dart index 9d1e0ac0..c71335c4 100644 --- a/lib/widget/stock_detail.dart +++ b/lib/widget/stock_detail.dart @@ -534,6 +534,18 @@ class _StockItemDisplayState extends RefreshableState { ); } + if (item.hasPurchasePrice) { + tiles.add( + ListTile( + title: Text(L10().purchasePrice), + leading: FaIcon(FontAwesomeIcons.dollarSign), + trailing: Text(item.purchasePrice), + ) + ); + } + + // TODO - Is this stock item linked to a PurchaseOrder? + // TODO - Re-enable stock item history display if (false && item.trackingItemCount > 0) { tiles.add( From 9b29f617c9b61c8dcb2ed0906b926d255d63b5f5 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 26 Jul 2021 23:35:36 +1000 Subject: [PATCH 25/26] bump release notes to 0.3.1 --- assets/release_notes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/release_notes.md b/assets/release_notes.md index 19b51b99..c18f04fc 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -1,7 +1,7 @@ ## InvenTree App Release Notes --- -### 0.3.0 - July 2021 +### 0.3.1 - July 2021 --- - Adds new "API driven" forms From 5b36b8e4e3512dcc572700335cddda519b5099c5 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 27 Jul 2021 08:41:12 +1000 Subject: [PATCH 26/26] Bump release version --- ios/Flutter/flutter_export_environment.sh | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ios/Flutter/flutter_export_environment.sh b/ios/Flutter/flutter_export_environment.sh index 559a5a7f..a12802c8 100644 --- a/ios/Flutter/flutter_export_environment.sh +++ b/ios/Flutter/flutter_export_environment.sh @@ -6,8 +6,8 @@ export "COCOAPODS_PARALLEL_CODE_SIGN=true" export "FLUTTER_TARGET=lib\main.dart" export "FLUTTER_BUILD_DIR=build" export "SYMROOT=${SOURCE_ROOT}/../build\ios" -export "FLUTTER_BUILD_NAME=0.2.10" -export "FLUTTER_BUILD_NUMBER=18" +export "FLUTTER_BUILD_NAME=0.3.1" +export "FLUTTER_BUILD_NUMBER=19" export "DART_OBFUSCATION=false" export "TRACK_WIDGET_CREATION=false" export "TREE_SHAKE_ICONS=false" diff --git a/pubspec.yaml b/pubspec.yaml index 17ccafaf..2b49b14c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,7 +7,7 @@ description: InvenTree stock management # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.2.10+18 +version: 0.3.1+19 environment: sdk: ">=2.12.0 <3.0.0"