2
0
mirror of https://github.com/inventree/inventree-app.git synced 2025-04-28 13:36:50 +00:00

Merge pull request #55 from SchrodingersGat/api-forms

Api forms
This commit is contained in:
Oliver 2021-07-27 08:41:48 +10:00 committed by GitHub
commit 51f3be899b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1040 additions and 307 deletions

View File

@ -1,6 +1,17 @@
## InvenTree App Release Notes ## 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 ### 0.2.10 - July 2021
--- ---

View File

@ -6,8 +6,8 @@ export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib\main.dart" export "FLUTTER_TARGET=lib\main.dart"
export "FLUTTER_BUILD_DIR=build" export "FLUTTER_BUILD_DIR=build"
export "SYMROOT=${SOURCE_ROOT}/../build\ios" export "SYMROOT=${SOURCE_ROOT}/../build\ios"
export "FLUTTER_BUILD_NAME=0.2.10" export "FLUTTER_BUILD_NAME=0.3.1"
export "FLUTTER_BUILD_NUMBER=18" export "FLUTTER_BUILD_NUMBER=19"
export "DART_OBFUSCATION=false" export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=false" export "TRACK_WIDGET_CREATION=false"
export "TREE_SHAKE_ICONS=false" export "TREE_SHAKE_ICONS=false"

View File

@ -2,20 +2,20 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:intl/intl.dart'; 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:flutter/cupertino.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:inventree/widget/dialogs.dart'; import 'package:inventree/widget/dialogs.dart';
import 'package:inventree/l10.dart'; import 'package:inventree/l10.dart';
import 'package:inventree/inventree/sentry.dart';
import 'package:http/http.dart' as http; import 'package:inventree/user_profile.dart';
import 'package:inventree/widget/snacks.dart';
/** /**
@ -93,7 +93,7 @@ class InvenTreeFileService extends FileService {
class InvenTreeAPI { class InvenTreeAPI {
// Minimum required API version for server // Minimum required API version for server
static const _minApiVersion = 6; static const _minApiVersion = 7;
// Endpoint for requesting an API token // Endpoint for requesting an API token
static const _URL_GET_TOKEN = "user/token/"; static const _URL_GET_TOKEN = "user/token/";
@ -128,7 +128,13 @@ class InvenTreeAPI {
String get imageUrl => _makeUrl("/image/"); 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); String makeUrl(String endpoint) => _makeUrl(endpoint);
@ -431,7 +437,7 @@ class InvenTreeAPI {
// Perform a PATCH request // Perform a PATCH request
Future<APIResponse> patch(String url, {Map<String, String> body = const {}, int expectedStatusCode=200}) async { Future<APIResponse> patch(String url, {Map<String, String> body = const {}, int? expectedStatusCode}) async {
var _body = Map<String, String>(); var _body = Map<String, String>();
// Copy across provided data // Copy across provided data
@ -593,8 +599,6 @@ class InvenTreeAPI {
Uri? _uri = Uri.tryParse(_url); Uri? _uri = Uri.tryParse(_url);
print("apiRequest ${method} -> ${url}");
if (_uri == null) { if (_uri == null) {
showServerError(L10().invalidHost, L10().invalidHostDetails); showServerError(L10().invalidHost, L10().invalidHostDetails);
return null; return null;
@ -621,12 +625,15 @@ class InvenTreeAPI {
return _request; return _request;
} on SocketException catch (error) { } on SocketException catch (error) {
print("SocketException at ${url}: ${error.toString()}");
showServerError(L10().connectionRefused, error.toString()); showServerError(L10().connectionRefused, error.toString());
return null; return null;
} on TimeoutException { } on TimeoutException {
print("TimeoutException at ${url}");
showTimeoutError(); showTimeoutError();
return null; return null;
} catch (error, stackTrace) { } catch (error, stackTrace) {
print("Server error at ${url}: ${error.toString()}");
showServerError(L10().serverError, error.toString()); showServerError(L10().serverError, error.toString());
sentryReportError(error, stackTrace); sentryReportError(error, stackTrace);
return null; return null;

681
lib/api_form.dart Normal file
View File

@ -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<String, String> get filters {
Map<String, String> _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<String> errorMessages() {
List<dynamic> errors = data['errors'] ?? [];
List<String> 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<dynamic> get choices => data["choices"] ?? [];
Future<void> loadInitialData() async {
// Only for "related fields"
if (type != "related field") {
return;
}
// Null value? No point!
if (value == null) {
return;
}
int? pk = int.tryParse(value.toString());
if (pk == null) {
return;
}
String url = api_url + "/" + pk.toString() + "/";
final APIResponse response = await InvenTreeAPI().get(
url,
params: filters,
);
if (response.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<dynamic>(
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<dynamic>(
mode: Mode.BOTTOM_SHEET,
showSelectedItem: true,
selectedItem: initial_data,
onFind: (String filter) async {
Map<String, String> _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<dynamic> 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<String, dynamic> 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<void> launchApiForm(BuildContext context, String title, String url, Map<String, dynamic> fields, {Map<String, dynamic> 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<APIFormField> 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<APIFormField> 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<APIFormWidget> {
final _formKey = new GlobalKey<FormState>();
String title;
String url;
List<APIFormField> fields;
Function? onSuccess;
_APIFormWidgetState(this.title, this.url, this.fields, this.onSuccess) : super();
List<Widget> _buildForm() {
List<Widget> 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<void> _save(BuildContext context) async {
// Package up the form data
Map<String, String> _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),
)
)
);
}
}

19
lib/app_colors.dart Normal file
View File

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

View File

@ -271,8 +271,7 @@ class StockItemBarcodeAssignmentHandler extends BarcodeHandler {
final InvenTreeStockItem item; final InvenTreeStockItem item;
StockItemBarcodeAssignmentHandler(this.item) { StockItemBarcodeAssignmentHandler(this.item);
}
@override @override
String getOverlayText(BuildContext context) => L10().barcodeScanAssign; String getOverlayText(BuildContext context) => L10().barcodeScanAssign;

View File

@ -126,8 +126,7 @@ class InvenTreeModel {
} }
// Return the API detail endpoint for this Model object // 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 // Search this Model type in the database
Future<List<InvenTreeModel>> search(BuildContext context, String searchTerm, {Map<String, String> filters = const {}}) async { Future<List<InvenTreeModel>> search(BuildContext context, String searchTerm, {Map<String, String> filters = const {}}) async {
@ -277,8 +276,6 @@ class InvenTreeModel {
params[key] = filters[key] ?? ''; params[key] = filters[key] ?? '';
} }
print("LIST: $URL ${params.toString()}");
var response = await api.get(URL, params: params); var response = await api.get(URL, params: params);
// A list of "InvenTreeModel" items // A list of "InvenTreeModel" items
@ -288,19 +285,23 @@ class InvenTreeModel {
return results; return results;
} }
// TODO - handle possible error cases: dynamic data;
// - No data receieved
// - Data is not a list of maps
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 // Create a new object (of the current class type
InvenTreeModel obj = createFromJson(d); InvenTreeModel obj = createFromJson(d);
if (obj != null) {
results.add(obj); results.add(obj);
} }
}
return results; return results;
} }

View File

@ -60,6 +60,8 @@ class InvenTreeStockItem extends InvenTreeModel {
String statusLabel(BuildContext context) { String statusLabel(BuildContext context) {
// TODO: Delete me - The translated status values are provided by the API!
switch (status) { switch (status) {
case OK: case OK:
return L10().ok; return L10().ok;
@ -220,6 +222,15 @@ class InvenTreeStockItem extends InvenTreeModel {
int get partId => jsondata['part'] ?? -1; 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; int get trackingItemCount => (jsondata['tracking_items'] ?? 0) as int;
// Date of last update // Date of last update
@ -476,14 +487,7 @@ class InvenTreeStockItem extends InvenTreeModel {
expectedStatusCode: 200 expectedStatusCode: 200
); );
print("Adjustment completed!"); return response.isValid();
if (response == null) {
return false;
}
// Stock adjustment succeeded!
return true;
} }
Future<bool> countStock(BuildContext context, double q, {String? notes}) async { Future<bool> countStock(BuildContext context, double q, {String? notes}) async {

@ -1 +1 @@
Subproject commit af4cd9026a96d44d60f9187119f5ce19c74738d3 Subproject commit 46d08c9cc0043113fee5c0d134861c5d12554b71

View File

@ -1,3 +1,4 @@
import 'package:inventree/app_colors.dart';
import 'package:inventree/widget/dialogs.dart'; import 'package:inventree/widget/dialogs.dart';
import 'package:inventree/widget/fields.dart'; import 'package:inventree/widget/fields.dart';
import 'package:inventree/widget/spinner.dart'; import 'package:inventree/widget/spinner.dart';
@ -55,7 +56,7 @@ class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
key: _addProfileKey, key: _addProfileKey,
callback: () { callback: () {
if (createNew) { if (createNew) {
// TODO - create the new profile...
UserProfile profile = UserProfile( UserProfile profile = UserProfile(
name: _name, name: _name,
server: _server, server: _server,
@ -219,7 +220,7 @@ class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
if ((InvenTreeAPI().profile?.key ?? '') != profile.key) { if ((InvenTreeAPI().profile?.key ?? '') != profile.key) {
return FaIcon( return FaIcon(
FontAwesomeIcons.questionCircle, FontAwesomeIcons.questionCircle,
color: Color.fromRGBO(250, 150, 50, 1) color: COLOR_WARNING
); );
} }
@ -227,17 +228,17 @@ class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
if (InvenTreeAPI().isConnected()) { if (InvenTreeAPI().isConnected()) {
return FaIcon( return FaIcon(
FontAwesomeIcons.checkCircle, FontAwesomeIcons.checkCircle,
color: Color.fromRGBO(50, 250, 50, 1) color: COLOR_SUCCESS
); );
} else if (InvenTreeAPI().isConnecting()) { } else if (InvenTreeAPI().isConnecting()) {
return Spinner( return Spinner(
icon: FontAwesomeIcons.spinner, icon: FontAwesomeIcons.spinner,
color: Color.fromRGBO(50, 50, 250, 1), color: COLOR_PROGRESS,
); );
} else { } else {
return FaIcon( return FaIcon(
FontAwesomeIcons.timesCircle, FontAwesomeIcons.timesCircle,
color: Color.fromRGBO(250, 50, 50, 1), color: COLOR_DANGER,
); );
} }
} }
@ -255,7 +256,7 @@ class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
title: Text( title: Text(
profile.name, profile.name,
), ),
tileColor: profile.selected ? Color.fromRGBO(0, 0, 0, 0.05) : null, tileColor: profile.selected ? COLOR_SELECTED : null,
subtitle: Text("${profile.server}"), subtitle: Text("${profile.server}"),
trailing: _getProfileIcon(profile), trailing: _getProfileIcon(profile),
onTap: () { onTap: () {

View File

@ -1,5 +1,6 @@
import 'package:inventree/api.dart'; import 'package:inventree/api.dart';
import 'package:inventree/app_colors.dart';
import 'package:inventree/app_settings.dart'; import 'package:inventree/app_settings.dart';
import 'package:inventree/inventree/part.dart'; import 'package:inventree/inventree/part.dart';
import 'package:inventree/inventree/sentry.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:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../api_form.dart';
class CategoryDisplayWidget extends StatefulWidget { class CategoryDisplayWidget extends StatefulWidget {
CategoryDisplayWidget(this.category, {Key? key}) : super(key: key); CategoryDisplayWidget(this.category, {Key? key}) : super(key: key);
@ -35,7 +38,6 @@ class CategoryDisplayWidget extends StatefulWidget {
class _CategoryDisplayState extends RefreshableState<CategoryDisplayWidget> { class _CategoryDisplayState extends RefreshableState<CategoryDisplayWidget> {
final _editCategoryKey = GlobalKey<FormState>();
@override @override
String getAppBarTitle(BuildContext context) => L10().partCategory; String getAppBarTitle(BuildContext context) => L10().partCategory;
@ -71,7 +73,9 @@ class _CategoryDisplayState extends RefreshableState<CategoryDisplayWidget> {
IconButton( IconButton(
icon: FaIcon(FontAwesomeIcons.edit), icon: FaIcon(FontAwesomeIcons.edit),
tooltip: L10().edit, tooltip: L10().edit,
onPressed: _editCategoryDialog, onPressed: () {
_editCategoryDialog(context);
},
) )
); );
} }
@ -80,49 +84,26 @@ class _CategoryDisplayState extends RefreshableState<CategoryDisplayWidget> {
} }
void _editCategory(Map<String, String> values) async { void _editCategoryDialog(BuildContext context) {
final bool result = await category!.update(values: values); final _cat = category;
showSnackIcon(
result ? "Category edited" : "Category editing failed",
success: result
);
refresh();
}
void _editCategoryDialog() {
// Cannot edit top-level category // Cannot edit top-level category
if (category == null) { if (_cat == null) {
return; return;
} }
var _name; launchApiForm(
var _description; context,
showFormDialog(
L10().editCategory, L10().editCategory,
key: _editCategoryKey, _cat.url,
callback: () { {
_editCategory({ "name": {},
"name": _name, "description": {},
"description": _description "parent": {},
});
}, },
fields: <Widget>[ modelData: _cat.jsondata,
StringField( onSuccess: refresh,
label: L10().name,
initial: category?.name,
onSaved: (value) => _name = value
),
StringField(
label: L10().description,
initial: category?.description,
onSaved: (value) => _description = value
)
]
); );
} }
@ -186,7 +167,10 @@ class _CategoryDisplayState extends RefreshableState<CategoryDisplayWidget> {
ListTile( ListTile(
title: Text(L10().parentCategory), title: Text(L10().parentCategory),
subtitle: Text("${category?.parentpathstring}"), subtitle: Text("${category?.parentpathstring}"),
leading: FaIcon(FontAwesomeIcons.levelUpAlt), leading: FaIcon(
FontAwesomeIcons.levelUpAlt,
color: COLOR_CLICK,
),
onTap: () { onTap: () {
if (category == null || ((category?.parentId ?? 0) < 0)) { if (category == null || ((category?.parentId ?? 0) < 0)) {
Navigator.push(context, MaterialPageRoute(builder: (context) => CategoryDisplayWidget(null))); Navigator.push(context, MaterialPageRoute(builder: (context) => CategoryDisplayWidget(null)));

View File

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:inventree/l10.dart'; import 'package:inventree/l10.dart';
import 'dart:async'; import 'dart:async';
@ -92,23 +91,28 @@ class ImagePickerField extends FormField<File> {
class CheckBoxField extends FormField<bool> { class CheckBoxField extends FormField<bool> {
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( super(
onSaved: onSaved, onSaved: onSaved,
initialValue: initial, initialValue: initial,
builder: (FormFieldState<bool> state) { builder: (FormFieldState<bool> state) {
return CheckboxListTile( return CheckboxListTile(
//dense: state.hasError, //dense: state.hasError,
title: label == null ? null : Text(label), title: label != null ? Text(label, style: labelStyle) : null,
value: state.value, value: state.value,
onChanged: state.didChange, onChanged: state.didChange,
subtitle: hint == null ? null : Text(hint), subtitle: helperText != null ? Text(helperText, style: helperStyle) : null,
contentPadding: EdgeInsets.zero,
); );
} }
); );
} }
class StringField extends TextFormField { class StringField extends TextFormField {
StringField({String label = "", String? hint, String? initial, Function(String?)? onSaved, Function? validator, bool allowEmpty = false, bool isEnabled = true}) : StringField({String label = "", String? hint, String? initial, Function(String?)? onSaved, Function? validator, bool allowEmpty = false, bool isEnabled = true}) :

View File

@ -1,3 +1,4 @@
import 'package:inventree/app_colors.dart';
import 'package:inventree/user_profile.dart'; import 'package:inventree/user_profile.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -19,6 +20,7 @@ import 'package:inventree/widget/spinner.dart';
import 'package:inventree/widget/drawer.dart'; import 'package:inventree/widget/drawer.dart';
class InvenTreeHomePage extends StatefulWidget { class InvenTreeHomePage extends StatefulWidget {
InvenTreeHomePage({Key? key}) : super(key: key); InvenTreeHomePage({Key? key}) : super(key: key);
@override @override
@ -130,7 +132,7 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
leading: FaIcon(FontAwesomeIcons.server), leading: FaIcon(FontAwesomeIcons.server),
trailing: FaIcon( trailing: FaIcon(
FontAwesomeIcons.user, FontAwesomeIcons.user,
color: Color.fromRGBO(250, 50, 50, 1), color: COLOR_DANGER,
), ),
onTap: () { onTap: () {
_selectProfile(); _selectProfile();
@ -146,7 +148,7 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
leading: FaIcon(FontAwesomeIcons.server), leading: FaIcon(FontAwesomeIcons.server),
trailing: Spinner( trailing: Spinner(
icon: FontAwesomeIcons.spinner, icon: FontAwesomeIcons.spinner,
color: Color.fromRGBO(50, 50, 250, 1), color: COLOR_PROGRESS,
), ),
onTap: () { onTap: () {
_selectProfile(); _selectProfile();
@ -159,7 +161,7 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
leading: FaIcon(FontAwesomeIcons.server), leading: FaIcon(FontAwesomeIcons.server),
trailing: FaIcon( trailing: FaIcon(
FontAwesomeIcons.checkCircle, FontAwesomeIcons.checkCircle,
color: Color.fromRGBO(50, 250, 50, 1) color: COLOR_SUCCESS
), ),
onTap: () { onTap: () {
_selectProfile(); _selectProfile();
@ -172,7 +174,7 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
leading: FaIcon(FontAwesomeIcons.server), leading: FaIcon(FontAwesomeIcons.server),
trailing: FaIcon( trailing: FaIcon(
FontAwesomeIcons.timesCircle, FontAwesomeIcons.timesCircle,
color: Color.fromRGBO(250, 50, 50, 1), color: COLOR_DANGER,
), ),
onTap: () { onTap: () {
_selectProfile(); _selectProfile();

View File

@ -1,4 +1,6 @@
import 'package:inventree/api.dart'; 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/app_settings.dart';
import 'package:inventree/barcode.dart'; import 'package:inventree/barcode.dart';
import 'package:inventree/inventree/sentry.dart'; import 'package:inventree/inventree/sentry.dart';
@ -71,7 +73,7 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> {
IconButton( IconButton(
icon: FaIcon(FontAwesomeIcons.edit), icon: FaIcon(FontAwesomeIcons.edit),
tooltip: L10().edit, tooltip: L10().edit,
onPressed: _editLocationDialog, onPressed: () { _editLocationDialog(context); },
) )
); );
} }
@ -79,23 +81,27 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> {
return actions; return actions;
} }
void _editLocation(Map<String, String> values) async { void _editLocationDialog(BuildContext context) {
bool result = false; final _loc = location;
if (location != null) { if (_loc == null) {
result = await location!.update(values: values); return;
}
showSnackIcon( launchApiForm(
result ? "Location edited" : "Location editing failed", context,
success: result L10().editLocation,
_loc.url,
{
"name": {},
"description": {},
"parent": {},
},
modelData: _loc.jsondata,
onSuccess: refresh
); );
}
refresh();
}
void _editLocationDialog() {
// Values which an be edited // Values which an be edited
var _name; var _name;
var _description; var _description;
@ -103,28 +109,6 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> {
if (location == null) { if (location == null) {
return; return;
} }
showFormDialog(L10().editLocation,
key: _editLocationKey,
callback: () {
_editLocation({
"name": _name,
"description": _description
});
},
fields: <Widget> [
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); _LocationDisplayState(this.location);
@ -193,7 +177,7 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> {
ListTile( ListTile(
title: Text(L10().parentCategory), title: Text(L10().parentCategory),
subtitle: Text("${location!.parentpathstring}"), subtitle: Text("${location!.parentpathstring}"),
leading: FaIcon(FontAwesomeIcons.levelUpAlt), leading: FaIcon(FontAwesomeIcons.levelUpAlt, color: COLOR_CLICK),
onTap: () { onTap: () {
int parent = location?.parentId ?? -1; int parent = location?.parentId ?? -1;
@ -319,7 +303,7 @@ List<Widget> detailTiles() {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text(L10().barcodeScanInItems), title: Text(L10().barcodeScanInItems),
leading: FaIcon(FontAwesomeIcons.exchangeAlt), leading: FaIcon(FontAwesomeIcons.exchangeAlt, color: COLOR_CLICK),
trailing: FaIcon(FontAwesomeIcons.qrcode), trailing: FaIcon(FontAwesomeIcons.qrcode),
onTap: () { onTap: () {

View File

@ -21,19 +21,14 @@ class PaginatedSearchWidget extends StatelessWidget {
leading: GestureDetector( leading: GestureDetector(
child: FaIcon(controller.text.isEmpty ? FontAwesomeIcons.search : FontAwesomeIcons.backspace), child: FaIcon(controller.text.isEmpty ? FontAwesomeIcons.search : FontAwesomeIcons.backspace),
onTap: () { onTap: () {
if (onChanged != null) {
controller.clear(); controller.clear();
onChanged(); onChanged();
}
}, },
), ),
title: TextFormField( title: TextFormField(
controller: controller, controller: controller,
onChanged: (value) { onChanged: (value) {
if (onChanged != null) {
onChanged(); onChanged();
}
}, },
decoration: InputDecoration( decoration: InputDecoration(
hintText: L10().search, hintText: L10().search,

View File

@ -1,14 +1,16 @@
import 'dart:io'; 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/cupertino.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:inventree/l10.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.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/inventree/part.dart';
import 'package:inventree/widget/full_screen_image.dart'; import 'package:inventree/widget/full_screen_image.dart';
import 'package:inventree/widget/category_display.dart'; import 'package:inventree/widget/category_display.dart';
@ -59,7 +61,9 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
IconButton( IconButton(
icon: FaIcon(FontAwesomeIcons.edit), icon: FaIcon(FontAwesomeIcons.edit),
tooltip: L10().edit, tooltip: L10().edit,
onPressed: _editPartDialog, onPressed: () {
_editPartDialog(context);
},
) )
); );
} }
@ -169,58 +173,36 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
); );
} }
void _editPartDialog() { void _editPartDialog(BuildContext context) {
// Values which can be edited launchApiForm(
var _name; context,
var _description; L10().editPart,
var _ipn; part.url,
var _keywords; {
var _link; "name": {},
"description": {},
"IPN": {},
"revision": {},
"keywords": {},
"link": {},
showFormDialog(L10().editPart, "category": {
key: _editPartKey,
callback: () {
_savePart({
"name": _name,
"description": _description,
"IPN": _ipn,
"keywords": _keywords,
"link": _link
});
}, },
fields: <Widget>[
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
)
]
);
// Checkbox fields
"active": {},
"assembly": {},
"component": {},
"purchaseable": {},
"salable": {},
"trackable": {},
"is_template": {},
"virtual": {},
},
modelData: part.jsondata,
onSuccess: refresh,
);
} }
Widget headerTile() { Widget headerTile() {
@ -230,7 +212,7 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
subtitle: Text("${part.description}"), subtitle: Text("${part.description}"),
trailing: IconButton( trailing: IconButton(
icon: FaIcon(part.starred ? FontAwesomeIcons.solidStar : FontAwesomeIcons.star, 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, onPressed: _toggleStar,
), ),
@ -264,13 +246,36 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
return tiles; 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 // Category information
if (part.categoryName.isNotEmpty) { if (part.categoryName.isNotEmpty) {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text(L10().partCategory), title: Text(L10().partCategory),
subtitle: Text("${part.categoryName}"), subtitle: Text("${part.categoryName}"),
leading: FaIcon(FontAwesomeIcons.sitemap), leading: FaIcon(FontAwesomeIcons.sitemap, color: COLOR_CLICK),
onTap: () { onTap: () {
if (part.categoryId > 0) { if (part.categoryId > 0) {
InvenTreePartCategory().get(part.categoryId).then((var cat) { InvenTreePartCategory().get(part.categoryId).then((var cat) {
@ -289,7 +294,7 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
ListTile( ListTile(
title: Text(L10().partCategory), title: Text(L10().partCategory),
subtitle: Text(L10().partCategoryTopLevel), subtitle: Text(L10().partCategoryTopLevel),
leading: FaIcon(FontAwesomeIcons.sitemap), leading: FaIcon(FontAwesomeIcons.sitemap, color: COLOR_CLICK),
onTap: () { onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (context) => CategoryDisplayWidget(null))); Navigator.push(context, MaterialPageRoute(builder: (context) => CategoryDisplayWidget(null)));
}, },
@ -301,7 +306,7 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text(L10().stock), title: Text(L10().stock),
leading: FaIcon(FontAwesomeIcons.boxes), leading: FaIcon(FontAwesomeIcons.boxes, color: COLOR_CLICK),
trailing: Text("${part.inStockString}"), trailing: Text("${part.inStockString}"),
onTap: () { onTap: () {
setState(() { setState(() {
@ -387,8 +392,7 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text("${part.link}"), title: Text("${part.link}"),
leading: FaIcon(FontAwesomeIcons.link), leading: FaIcon(FontAwesomeIcons.link, color: COLOR_CLICK),
trailing: FaIcon(FontAwesomeIcons.externalLinkAlt),
onTap: () { onTap: () {
part.openLink(); part.openLink();
}, },
@ -412,7 +416,7 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text(L10().notes), title: Text(L10().notes),
leading: FaIcon(FontAwesomeIcons.stickyNote), leading: FaIcon(FontAwesomeIcons.stickyNote, color: COLOR_CLICK),
trailing: Text(""), trailing: Text(""),
onTap: () { onTap: () {
Navigator.push( Navigator.push(

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:inventree/app_colors.dart';
class Spinner extends StatefulWidget { class Spinner extends StatefulWidget {
final IconData? icon; final IconData? icon;
@ -9,7 +10,7 @@ class Spinner extends StatefulWidget {
final Color color; final Color color;
const Spinner({ const Spinner({
this.color = const Color.fromRGBO(150, 150, 150, 1), this.color = COLOR_GRAY_LIGHT,
Key? key, Key? key,
@required this.icon, @required this.icon,
this.duration = const Duration(milliseconds: 1800), this.duration = const Duration(milliseconds: 1800),

View File

@ -1,4 +1,6 @@
import 'package:inventree/app_colors.dart';
import 'package:inventree/barcode.dart'; import 'package:inventree/barcode.dart';
import 'package:inventree/inventree/model.dart';
import 'package:inventree/inventree/stock.dart'; import 'package:inventree/inventree/stock.dart';
import 'package:inventree/inventree/part.dart'; import 'package:inventree/inventree/part.dart';
import 'package:inventree/widget/dialogs.dart'; import 'package:inventree/widget/dialogs.dart';
@ -17,9 +19,11 @@ import 'package:inventree/l10.dart';
import 'package:inventree/api.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 'package:font_awesome_flutter/font_awesome_flutter.dart';
import '../api_form.dart';
class StockDetailWidget extends StatefulWidget { class StockDetailWidget extends StatefulWidget {
StockDetailWidget(this.item, {Key? key}) : super(key: key); StockDetailWidget(this.item, {Key? key}) : super(key: key);
@ -49,20 +53,29 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
@override @override
List<Widget> getAppBarActions(BuildContext context) { List<Widget> getAppBarActions(BuildContext context) {
return <Widget>[
List<Widget> actions = [];
if (InvenTreeAPI().checkPermission('stock', 'view')) {
actions.add(
IconButton( IconButton(
icon: FaIcon(FontAwesomeIcons.globe), icon: FaIcon(FontAwesomeIcons.globe),
onPressed: _openInvenTreePage, onPressed: _openInvenTreePage,
), )
// TODO: Hide the 'edit' button if the user does not have permission!! );
/* }
if (InvenTreeAPI().checkPermission('stock', 'change')) {
actions.add(
IconButton( IconButton(
icon: FaIcon(FontAwesomeIcons.edit), icon: FaIcon(FontAwesomeIcons.edit),
tooltip: L10().edit, tooltip: L10().edit,
onPressed: _editPartDialog, onPressed: () { _editStockItem(context); },
) )
*/ );
]; }
return actions;
} }
Future<void> _openInvenTreePage() async { Future<void> _openInvenTreePage() async {
@ -95,6 +108,24 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
await item.getTestResults(); 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 { void _addStock() async {
double quantity = double.parse(_quantityController.text); double quantity = double.parse(_quantityController.text);
@ -241,7 +272,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
} }
void _transferStock(InvenTreeStockLocation location) async { void _transferStock(int locationId) async {
double quantity = double.tryParse(_quantityController.text) ?? item.quantity; double quantity = double.tryParse(_quantityController.text) ?? item.quantity;
String notes = _notesController.text; String notes = _notesController.text;
@ -249,7 +280,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
_quantityController.clear(); _quantityController.clear();
_notesController.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(); refresh();
@ -258,22 +289,22 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
} }
} }
void _transferStockDialog() async { void _transferStockDialog(BuildContext context) async {
var locations = await InvenTreeStockLocation().list(); var locations = await InvenTreeStockLocation().list();
final _selectedController = TextEditingController(); final _selectedController = TextEditingController();
InvenTreeStockLocation? selectedLocation; int? location_pk;
_quantityController.text = "${item.quantityString}"; _quantityController.text = "${item.quantityString}";
showFormDialog(L10().transferStock, showFormDialog(L10().transferStock,
key: _moveStockKey, key: _moveStockKey,
callback: () { callback: () {
var _loc = selectedLocation; var _pk = location_pk;
if (_loc != null) { if (_pk != null) {
_transferStock(_loc); _transferStock(_pk);
} }
}, },
fields: <Widget>[ fields: <Widget>[
@ -282,47 +313,57 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
controller: _quantityController, controller: _quantityController,
max: item.quantity, max: item.quantity,
), ),
TypeAheadFormField( DropdownSearch<dynamic>(
textFieldConfiguration: TextFieldConfiguration( mode: Mode.BOTTOM_SHEET,
controller: _selectedController, showSelectedItem: false,
autofocus: true, autoFocusSearchBox: true,
decoration: InputDecoration( selectedItem: null,
hintText: L10().searchLocation, errorBuilder: (context, entry, exception) {
border: OutlineInputBorder() print("entry: $entry");
print(exception.toString());
return Text(
exception.toString(),
style: TextStyle(
fontSize: 10,
) )
),
suggestionsCallback: (pattern) async {
List<InvenTreeStockLocation> suggestions = [];
for (var loc in locations) {
if (loc.matchAgainstString(pattern)) {
suggestions.add(loc as InvenTreeStockLocation);
}
}
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}"),
); );
},
onFind: (String filter) async {
Map<String, String> _filters = {
"search": filter,
"offset": "0",
"limit": "25"
};
final List<InvenTreeModel> results = await InvenTreeStockLocation().list(filters: _filters);
List<dynamic> items = [];
for (InvenTreeModel loc in results) {
if (loc is InvenTreeStockLocation) {
items.add(loc.jsondata);
} }
}
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<StockDetailWidget> {
ListTile( ListTile(
title: Text(L10().stockLocation), title: Text(L10().stockLocation),
subtitle: Text("${item.locationPathString}"), subtitle: Text("${item.locationPathString}"),
leading: FaIcon(FontAwesomeIcons.mapMarkerAlt), leading: FaIcon(
FontAwesomeIcons.mapMarkerAlt,
color: COLOR_CLICK,
),
onTap: () { onTap: () {
if (item.locationId > 0) { if (item.locationId > 0) {
InvenTreeStockLocation().get(item.locationId).then((var loc) { InvenTreeStockLocation().get(item.locationId).then((var loc) {
@ -463,9 +507,10 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text("${item.link}"), title: Text("${item.link}"),
leading: FaIcon(FontAwesomeIcons.link), leading: FaIcon(FontAwesomeIcons.link, color: COLOR_CLICK),
trailing: Text(""), onTap: () {
onTap: null, item.openLink();
},
) )
); );
} }
@ -474,7 +519,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text(L10().testResults), title: Text(L10().testResults),
leading: FaIcon(FontAwesomeIcons.tasks), leading: FaIcon(FontAwesomeIcons.tasks, color: COLOR_CLICK),
trailing: Text("${item.testResultCount}"), trailing: Text("${item.testResultCount}"),
onTap: () { onTap: () {
Navigator.push( Navigator.push(
@ -489,6 +534,18 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
); );
} }
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 // TODO - Re-enable stock item history display
if (false && item.trackingItemCount > 0) { if (false && item.trackingItemCount > 0) {
tiles.add( tiles.add(
@ -510,8 +567,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text(L10().notes), title: Text(L10().notes),
leading: FaIcon(FontAwesomeIcons.stickyNote), leading: FaIcon(FontAwesomeIcons.stickyNote, color: COLOR_CLICK),
trailing: Text(""),
onTap: () { onTap: () {
Navigator.push( Navigator.push(
context, context,
@ -527,7 +583,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
return tiles; return tiles;
} }
List<Widget> actionTiles() { List<Widget> actionTiles(BuildContext context) {
List<Widget> tiles = []; List<Widget> tiles = [];
tiles.add(headerTile()); tiles.add(headerTile());
@ -554,7 +610,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text(L10().countStock), title: Text(L10().countStock),
leading: FaIcon(FontAwesomeIcons.checkCircle), leading: FaIcon(FontAwesomeIcons.checkCircle, color: COLOR_CLICK),
onTap: _countStockDialog, onTap: _countStockDialog,
trailing: Text(item.quantityString), trailing: Text(item.quantityString),
) )
@ -563,7 +619,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text(L10().removeStock), title: Text(L10().removeStock),
leading: FaIcon(FontAwesomeIcons.minusCircle), leading: FaIcon(FontAwesomeIcons.minusCircle, color: COLOR_CLICK),
onTap: _removeStockDialog, onTap: _removeStockDialog,
) )
); );
@ -571,7 +627,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text(L10().addStock), title: Text(L10().addStock),
leading: FaIcon(FontAwesomeIcons.plusCircle), leading: FaIcon(FontAwesomeIcons.plusCircle, color: COLOR_CLICK),
onTap: _addStockDialog, onTap: _addStockDialog,
) )
); );
@ -580,8 +636,8 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text(L10().transferStock), title: Text(L10().transferStock),
leading: FaIcon(FontAwesomeIcons.exchangeAlt), leading: FaIcon(FontAwesomeIcons.exchangeAlt, color: COLOR_CLICK),
onTap: _transferStockDialog, onTap: () { _transferStockDialog(context); },
) )
); );
@ -589,7 +645,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text(L10().scanIntoLocation), title: Text(L10().scanIntoLocation),
leading: FaIcon(FontAwesomeIcons.exchangeAlt), leading: FaIcon(FontAwesomeIcons.exchangeAlt, color: COLOR_CLICK),
trailing: FaIcon(FontAwesomeIcons.qrcode), trailing: FaIcon(FontAwesomeIcons.qrcode),
onTap: () { onTap: () {
Navigator.push( Navigator.push(
@ -607,7 +663,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text(L10().barcodeAssign), title: Text(L10().barcodeAssign),
leading: FaIcon(FontAwesomeIcons.barcode), leading: FaIcon(FontAwesomeIcons.barcode, color: COLOR_CLICK),
trailing: FaIcon(FontAwesomeIcons.qrcode), trailing: FaIcon(FontAwesomeIcons.qrcode),
onTap: () { onTap: () {
Navigator.push( Navigator.push(
@ -623,7 +679,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text(L10().barcodeUnassign), title: Text(L10().barcodeUnassign),
leading: FaIcon(FontAwesomeIcons.barcode), leading: FaIcon(FontAwesomeIcons.barcode, color: COLOR_CLICK),
onTap: () { onTap: () {
_unassignBarcode(context); _unassignBarcode(context);
} }
@ -665,7 +721,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
return ListView( return ListView(
children: ListTile.divideTiles( children: ListTile.divideTiles(
context: context, context: context,
tiles: actionTiles() tiles: actionTiles(context)
).toList() ).toList()
); );
default: default:

View File

@ -1,3 +1,4 @@
import 'package:inventree/app_colors.dart';
import 'package:inventree/inventree/part.dart'; import 'package:inventree/inventree/part.dart';
import 'package:inventree/inventree/stock.dart'; import 'package:inventree/inventree/stock.dart';
import 'package:inventree/inventree/model.dart'; import 'package:inventree/inventree/model.dart';
@ -84,7 +85,7 @@ class _StockItemTestResultDisplayState extends RefreshableState<StockItemTestRes
), ),
CheckBoxField( CheckBoxField(
label: L10().result, label: L10().result,
hint: L10().testPassedOrFailed, helperText: L10().testPassedOrFailed,
initial: true, initial: true,
onSaved: (value) => _result = value ?? false, onSaved: (value) => _result = value ?? false,
), ),
@ -207,7 +208,7 @@ class _StockItemTestResultDisplayState extends RefreshableState<StockItemTestRes
String _value = ""; String _value = "";
String _notes = ""; String _notes = "";
FaIcon _icon = FaIcon(FontAwesomeIcons.questionCircle, color: Color.fromRGBO(0, 0, 250, 1)); FaIcon _icon = FaIcon(FontAwesomeIcons.questionCircle, color: COLOR_BLUE);
bool _valueRequired = false; bool _valueRequired = false;
bool _attachmentRequired = false; bool _attachmentRequired = false;
@ -229,11 +230,11 @@ class _StockItemTestResultDisplayState extends RefreshableState<StockItemTestRes
if (_result == true) { if (_result == true) {
_icon = FaIcon(FontAwesomeIcons.checkCircle, _icon = FaIcon(FontAwesomeIcons.checkCircle,
color: Color.fromRGBO(0, 250, 0, 0.8) color: COLOR_SUCCESS,
); );
} else if (_result == false) { } else if (_result == false) {
_icon = FaIcon(FontAwesomeIcons.timesCircle, _icon = FaIcon(FontAwesomeIcons.timesCircle,
color: Color.fromRGBO(250, 0, 0, 0.8) color: COLOR_DANGER,
); );
} }

View File

@ -127,6 +127,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.1" version: "2.0.1"
dropdown_search:
dependency: "direct main"
description:
name: dropdown_search
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.3"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -167,27 +174,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.1.2" 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: flutter_launcher_icons:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -226,13 +212,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: flutter_web_plugins:
dependency: transitive dependency: transitive
description: flutter description: flutter

View File

@ -7,7 +7,7 @@ description: InvenTree stock management
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 0.2.10+18 version: 0.3.1+19
environment: environment:
sdk: ">=2.12.0 <3.0.0" sdk: ">=2.12.0 <3.0.0"
@ -30,7 +30,6 @@ dependencies:
font_awesome_flutter: ^9.1.0 # FontAwesome icon set font_awesome_flutter: ^9.1.0 # FontAwesome icon set
flutter_speed_dial: ^3.0.5 # FAB menu elements flutter_speed_dial: ^3.0.5 # FAB menu elements
sentry_flutter: 5.0.0 # Error reporting 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 image_picker: ^0.8.0 # Select or take photos
url_launcher: 6.0.0 # Open link in system browser url_launcher: 6.0.0 # Open link in system browser
flutter_markdown: ^0.6.2 # Rendering markdown flutter_markdown: ^0.6.2 # Rendering markdown
@ -40,6 +39,7 @@ dependencies:
one_context: ^1.1.0 # Dialogs without requiring context one_context: ^1.1.0 # Dialogs without requiring context
infinite_scroll_pagination: ^3.1.0 # Let the server do all the work! infinite_scroll_pagination: ^3.1.0 # Let the server do all the work!
audioplayers: ^0.19.0 # Play audio files audioplayers: ^0.19.0 # Play audio files
dropdown_search: 0.6.3 # Dropdown autocomplete form fields
path: path:
dev_dependencies: dev_dependencies: