diff --git a/assets/release_notes.md b/assets/release_notes.md index 6b7bc7d2..c18f04fc 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -1,6 +1,17 @@ ## InvenTree App Release Notes --- +### 0.3.1 - 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 +- Display purchase price (where available) for StockItem +- Updated translations + ### 0.2.10 - July 2021 --- 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/lib/api.dart b/lib/api.dart index 1bbcbf0f..843cfeb4 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'; /** @@ -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/"; @@ -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); @@ -431,7 +437,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 @@ -593,8 +599,6 @@ class InvenTreeAPI { Uri? _uri = Uri.tryParse(_url); - print("apiRequest ${method} -> ${url}"); - if (_uri == null) { showServerError(L10().invalidHost, L10().invalidHostDetails); return null; @@ -621,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; @@ -809,4 +816,4 @@ class InvenTreeAPI { cacheManager: manager, ); } -} \ No newline at end of file +} diff --git a/lib/api_form.dart b/lib/api_form.dart new file mode 100644 index 00000000..750aea6c --- /dev/null +++ b/lib/api_form.dart @@ -0,0 +1,681 @@ +import 'dart:ui'; + +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/inventree/part.dart'; +import 'package:inventree/inventree/stock.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'; + + + +/* + * Class that represents a single "form field", + * defined by the InvenTree API + */ +class APIFormField { + + final _controller = TextEditingController(); + + // Constructor + APIFormField(this.name, this.data); + + // Name of this field + final String name; + + // 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; + + // 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']; + + 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 + 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; + + String get type => (data['type'] ?? '').toString(); + + String get label => (data['label'] ?? '').toString(); + + String get helpText => (data['help_text'] ?? '').toString(); + + String get placeholderText => (data['placeholder'] ?? '').toString(); + + List get choices => data["choices"] ?? []; + + 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) { + case "string": + case "url": + return _constructString(); + case "boolean": + return _constructBoolean(); + case "related field": + return _constructRelatedField(); + case "choice": + return _constructChoiceField(); + default: + return ListTile( + title: Text( + "Unsupported field type: '${type}'", + style: TextStyle( + color: COLOR_DANGER, + fontStyle: FontStyle.italic), + ) + ); + } + } + + 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, + autoFocusSearchBox: true, + 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() { + + return DropdownSearch( + mode: Mode.BOTTOM_SHEET, + showSelectedItem: true, + selectedItem: initial_data, + onFind: (String filter) async { + + Map _filters = {}; + + filters.forEach((key, value) { + _filters[key] = value; + }); + + _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); + } + + return results; + } else { + return []; + } + }, + label: label, + hint: helpText, + onChanged: null, + showClearButton: !required, + itemAsString: (dynamic item) { + return item['pathstring']; + }, + dropdownBuilder: (context, item, itemAsString) { + return _renderRelatedField(item, true, false); + }, + popupItemBuilder: (context, item, isSelected) { + return _renderRelatedField(item, isSelected, true); + }, + onSaved: (item) { + 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; + } + + return item['pk'] == selectedItem['pk']; + } + ); + } + + 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, + ); + 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( + "Unsupported model", + style: TextStyle( + fontWeight: FontWeight.bold, + color: COLOR_DANGER + ) + ), + subtitle: Text("Model '${model}' rendering not supported"), + ); + } + + } + + // Construct a string input element + Widget _constructString() { + + return TextFormField( + decoration: InputDecoration( + labelText: required ? label + "*" : label, + labelStyle: _labelStyle(), + helperText: helpText, + helperStyle: _helperStyle(), + hintText: placeholderText, + ), + initialValue: value ?? '', + onSaved: (val) { + data["value"] = val; + }, + validator: (value) { + if (required && (value == null || value.isEmpty)) { + // return L10().valueCannotBeEmpty; + } + }, + ); + } + + // Construct a boolean input element + Widget _constructBoolean() { + + return CheckBoxField( + label: label, + labelStyle: _labelStyle(), + helperText: helpText, + helperStyle: _helperStyle(), + initial: value, + onSaved: (val) { + data['value'] = val; + }, + ); + } + + TextStyle _labelStyle() { + return new TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + fontFamily: "arial", + color: hasErrors() ? COLOR_DANGER : COLOR_GRAY, + fontStyle: FontStyle.normal, + ); + } + + TextStyle _helperStyle() { + return new TextStyle( + fontStyle: FontStyle.italic, + color: hasErrors() ? COLOR_DANGER : COLOR_GRAY, + ); + } + +} + + +/* + * Extract field options from a returned OPTIONS request + */ +Map extractFields(APIResponse response) { + + if (!response.isValid()) { + return {}; + } + + if (!response.data.containsKey("actions")) { + return {}; + } + + var actions = response.data["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! + * + * @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(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); + + // Invalid response from server + if (!options.isValid()) { + return; + } + + var availableFields = extractFields(options); + + if (availableFields.isEmpty) { + // User does not have permission to perform this action + showSnackIcon( + L10().response403, + icon: FontAwesomeIcons.userTimes, + ); + + return; + } + + // Construct a list of APIFormField objects + List formFields = []; + + // 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 must be taken here! + if (key == "filters") { + + if (!remoteField.containsKey("filters")) { + remoteField["filters"] = {}; + } + + var filters = localField["filters"]; + + if (filters is Map) { + filters.forEach((key, value) { + remoteField["filters"][key] = value; + }); + } + + } else { + remoteField[key] = localField[key]; + } + } + + // 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)); + } + + // Grab existing data for each form field + for (var field in formFields) { + await field.loadInitialData(); + } + + // Now, launch a new widget! + Navigator.push( + context, + MaterialPageRoute(builder: (context) => APIFormWidget( + title, + url, + formFields, + onSuccess: onSuccess, + )) + ); +} + + +class APIFormWidget extends StatefulWidget { + + //! Form title to display + final String title; + + //! API URL + final String url; + + final List fields; + + Function? onSuccess; + + APIFormWidget( + this.title, + this.url, + this.fields, + { + Key? key, + this.onSuccess, + } + ) : super(key: key); + + @override + _APIFormWidgetState createState() => _APIFormWidgetState(title, url, fields, onSuccess); + +} + + +class _APIFormWidgetState extends State { + + final _formKey = new GlobalKey(); + + String title; + + String url; + + List fields; + + Function? onSuccess; + + _APIFormWidgetState(this.title, this.url, this.fields, this.onSuccess) : super(); + + List _buildForm() { + + List widgets = []; + + for (var field in fields) { + + if (field.hidden) { + continue; + } + + widgets.add(field.constructField()); + + if (field.hasErrors()) { + for (String error in field.errorMessages()) { + widgets.add( + ListTile( + title: Text( + error, + style: TextStyle( + color: COLOR_DANGER, + fontStyle: FontStyle.italic, + fontSize: 16, + ), + ) + ) + ); + } + } + } + + return widgets; + } + + Future _save(BuildContext context) async { + + // Package up the form data + Map _data = {}; + + for (var field in fields) { + + dynamic value = field.value; + + if (value == null) { + _data[field.name] = ""; + } else { + _data[field.name] = value.toString(); + } + } + + // TODO: Handle "POST" forms too!! + final response = await InvenTreeAPI().patch( + url, + body: _data, + ); + + if (!response.isValid()) { + // TODO: Display an error message! + return; + } + + switch (response.statusCode) { + case 200: + case 201: + // Form was successfully validated by the server + + // Hide this form + Navigator.pop(context); + + // TODO: Display a snackBar + + // Run custom onSuccess function + var successFunc = onSuccess; + + if (successFunc != null) { + successFunc(); + } + return; + case 400: + // Form submission / validation error + + // Update field errors + for (var field in fields) { + field.data['errors'] = response.data[field.name]; + } + break; + // TODO: Other status codes? + } + + setState(() { + // Refresh the form + }); + + } + + @override + Widget build(BuildContext context) { + + return Scaffold( + appBar: AppBar( + title: Text(title), + actions: [ + IconButton( + icon: FaIcon(FontAwesomeIcons.save), + onPressed: () { + + if (_formKey.currentState!.validate()) { + _formKey.currentState!.save(); + + _save(context); + } + }, + ) + ] + ), + body: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: _buildForm(), + ), + padding: EdgeInsets.all(16), + ) + ) + ); + + } +} \ No newline at end of file diff --git a/lib/app_colors.dart b/lib/app_colors.dart new file mode 100644 index 00000000..a4797c6a --- /dev/null +++ b/lib/app_colors.dart @@ -0,0 +1,19 @@ + + +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); + +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/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 a891636a..62d2fae0 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 { @@ -277,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 @@ -288,18 +285,22 @@ 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); - 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..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 @@ -476,14 +487,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/l10n b/lib/l10n index af4cd902..46d08c9c 160000 --- a/lib/l10n +++ b/lib/l10n @@ -1 +1 @@ -Subproject commit af4cd9026a96d44d60f9187119f5ce19c74738d3 +Subproject commit 46d08c9cc0043113fee5c0d134861c5d12554b71 diff --git a/lib/settings/login.dart b/lib/settings/login.dart index 977e558c..5112161a 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'; @@ -55,7 +56,7 @@ class _InvenTreeLoginSettingsState extends State { key: _addProfileKey, callback: () { if (createNew) { - // TODO - create the new profile... + UserProfile profile = UserProfile( name: _name, server: _server, @@ -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/category_display.dart b/lib/widget/category_display.dart index 62ac1ad7..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'; @@ -22,6 +23,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 +38,6 @@ class CategoryDisplayWidget extends StatefulWidget { class _CategoryDisplayState extends RefreshableState { - final _editCategoryKey = GlobalKey(); @override String getAppBarTitle(BuildContext context) => L10().partCategory; @@ -71,7 +73,9 @@ class _CategoryDisplayState extends RefreshableState { IconButton( icon: FaIcon(FontAwesomeIcons.edit), tooltip: L10().edit, - onPressed: _editCategoryDialog, + onPressed: () { + _editCategoryDialog(context); + }, ) ); } @@ -80,49 +84,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, ); } @@ -186,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/fields.dart b/lib/widget/fields.dart index 6d004a43..75abe85e 100644 --- a/lib/widget/fields.dart +++ b/lib/widget/fields.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; - import 'package:inventree/l10.dart'; import 'dart:async'; @@ -92,23 +91,28 @@ class ImagePickerField extends FormField { 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, ); } ); } - 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/home.dart b/lib/widget/home.dart index 1c5bc55d..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'; @@ -19,6 +20,7 @@ import 'package:inventree/widget/spinner.dart'; import 'package:inventree/widget/drawer.dart'; class InvenTreeHomePage extends StatefulWidget { + InvenTreeHomePage({Key? key}) : super(key: key); @override @@ -130,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(); @@ -146,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(); @@ -159,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(); @@ -172,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/location_display.dart b/lib/widget/location_display.dart index a6313382..93bacd2e 100644 --- a/lib/widget/location_display.dart +++ b/lib/widget/location_display.dart @@ -1,4 +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'; @@ -71,7 +73,7 @@ class _LocationDisplayState extends RefreshableState { IconButton( icon: FaIcon(FontAwesomeIcons.edit), tooltip: L10().edit, - onPressed: _editLocationDialog, + onPressed: () { _editLocationDialog(context); }, ) ); } @@ -79,23 +81,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 +109,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); @@ -193,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; @@ -319,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/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/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index 8fa6c89d..9fb5037a 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -1,14 +1,16 @@ import 'dart:io'; -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/app_colors.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'; @@ -59,7 +61,9 @@ class _PartDisplayState extends RefreshableState { IconButton( icon: FaIcon(FontAwesomeIcons.edit), tooltip: L10().edit, - onPressed: _editPartDialog, + onPressed: () { + _editPartDialog(context); + }, ) ); } @@ -169,58 +173,36 @@ class _PartDisplayState extends RefreshableState { ); } - void _editPartDialog() { + void _editPartDialog(BuildContext context) { - // Values which can be edited - var _name; - var _description; - var _ipn; - var _keywords; - var _link; + launchApiForm( + context, + L10().editPart, + part.url, + { + "name": {}, + "description": {}, + "IPN": {}, + "revision": {}, + "keywords": {}, + "link": {}, - 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 - ) - ] + "category": { + }, + + // Checkbox fields + "active": {}, + "assembly": {}, + "component": {}, + "purchaseable": {}, + "salable": {}, + "trackable": {}, + "is_template": {}, + "virtual": {}, + }, + modelData: part.jsondata, + onSuccess: refresh, ); - } Widget headerTile() { @@ -230,7 +212,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, ), @@ -264,13 +246,36 @@ 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( 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) { @@ -289,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))); }, @@ -301,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(() { @@ -387,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(); }, @@ -412,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( @@ -539,4 +543,4 @@ class _PartDisplayState extends RefreshableState { Widget getBody(BuildContext context) { return getSelectedWidget(tabIndex); } -} \ No newline at end of file +} 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_detail.dart b/lib/widget/stock_detail.dart index 1029b021..c71335c4 100644 --- a/lib/widget/stock_detail.dart +++ b/lib/widget/stock_detail.dart @@ -1,4 +1,6 @@ +import 'package:inventree/app_colors.dart'; 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,9 +19,11 @@ 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'; + class StockDetailWidget extends StatefulWidget { StockDetailWidget(this.item, {Key? key}) : super(key: key); @@ -49,20 +53,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 +108,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); @@ -241,7 +272,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; @@ -249,7 +280,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(); @@ -258,22 +289,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: [ @@ -282,47 +313,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, ), ], ); @@ -394,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) { @@ -463,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(); + }, ) ); } @@ -474,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( @@ -489,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( @@ -510,8 +567,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, @@ -527,7 +583,7 @@ class _StockItemDisplayState extends RefreshableState { return tiles; } - List actionTiles() { + List actionTiles(BuildContext context) { List tiles = []; tiles.add(headerTile()); @@ -554,7 +610,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), ) @@ -563,7 +619,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, ) ); @@ -571,7 +627,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, ) ); @@ -580,8 +636,8 @@ class _StockItemDisplayState extends RefreshableState { tiles.add( ListTile( title: Text(L10().transferStock), - leading: FaIcon(FontAwesomeIcons.exchangeAlt), - onTap: _transferStockDialog, + leading: FaIcon(FontAwesomeIcons.exchangeAlt, color: COLOR_CLICK), + onTap: () { _transferStockDialog(context); }, ) ); @@ -589,7 +645,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( @@ -607,7 +663,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( @@ -623,7 +679,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); } @@ -665,7 +721,7 @@ class _StockItemDisplayState extends RefreshableState { return ListView( children: ListTile.divideTiles( context: context, - tiles: actionTiles() + tiles: actionTiles(context) ).toList() ); default: diff --git a/lib/widget/stock_item_test_results.dart b/lib/widget/stock_item_test_results.dart index 0851224e..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'; @@ -84,7 +85,7 @@ class _StockItemTestResultDisplayState extends RefreshableState _result = value ?? false, ), @@ -207,7 +208,7 @@ class _StockItemTestResultDisplayState extends RefreshableState=2.12.0 <3.0.0" @@ -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 @@ -40,6 +39,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: