2
0
mirror of https://github.com/inventree/inventree-app.git synced 2025-05-16 05:53:10 +00:00

Merge branch 'master' of github.com:SchrodingersGat/inventreeapp

This commit is contained in:
Oliver Walters 2021-08-18 20:03:35 +10:00
commit 9f811f1dbf
15 changed files with 550 additions and 452 deletions

View File

@ -1,6 +1,14 @@
## InvenTree App Release Notes ## InvenTree App Release Notes
--- ---
### 0.4.6 - August 2021
---
- Improved profile selection screen
- Fixed a number of incorrect labels
- Refactor test result upload functionality
- Refactor file selection and upload functions
### 0.4.5 - August 2021 ### 0.4.5 - August 2021
--- ---

View File

@ -42,6 +42,14 @@ class APIResponse {
// Request is "valid" if a statusCode was returned // Request is "valid" if a statusCode was returned
bool isValid() => (statusCode >= 0) && (statusCode < 500); bool isValid() => (statusCode >= 0) && (statusCode < 500);
bool successful() => (statusCode >= 200) && (statusCode < 300);
bool redirected() => (statusCode >= 300) && (statusCode < 400);
bool clientError() => (statusCode >= 400) && (statusCode < 500);
bool serverError() => (statusCode >= 500);
} }
@ -254,12 +262,7 @@ class InvenTreeAPI {
response = await get("", expectedStatusCode: 200); response = await get("", expectedStatusCode: 200);
// Response was invalid for some reason if (!response.successful()) {
if (!response.isValid()) {
return false;
}
if (response.statusCode != 200) {
showStatusCodeError(response.statusCode); showStatusCodeError(response.statusCode);
return false; return false;
} }
@ -308,16 +311,10 @@ class InvenTreeAPI {
// Clear the existing token value // Clear the existing token value
_token = ""; _token = "";
print("Requesting token from server");
response = await get(_URL_GET_TOKEN); response = await get(_URL_GET_TOKEN);
// Invalid response // Invalid response
if (!response.isValid()) { if (!response.successful()) {
return false;
}
if (response.statusCode != 200) {
switch (response.statusCode) { switch (response.statusCode) {
case 401: case 401:
@ -413,7 +410,7 @@ class InvenTreeAPI {
var response = await get(_URL_GET_ROLES, expectedStatusCode: 200); var response = await get(_URL_GET_ROLES, expectedStatusCode: 200);
if (!response.isValid() || response.statusCode != 200) { if (!response.successful()) {
return; return;
} }
@ -563,9 +560,8 @@ class InvenTreeAPI {
/* /*
* Upload a file to the given URL * Upload a file to the given URL
*/ */
Future<http.StreamedResponse> uploadFile(String url, File f, Future<APIResponse> uploadFile(String url, File f,
{String name = "attachment", String method="POST", Map<String, String>? fields}) async { {String name = "attachment", String method="POST", Map<String, String>? fields}) async {
var _url = makeApiUrl(url); var _url = makeApiUrl(url);
var request = http.MultipartRequest(method, Uri.parse(_url)); var request = http.MultipartRequest(method, Uri.parse(_url));
@ -582,25 +578,63 @@ class InvenTreeAPI {
request.files.add(_file); request.files.add(_file);
var response = await request.send(); APIResponse response = APIResponse(
url: url,
method: method,
);
if (response.statusCode >= 500) { String jsondata = "";
// Server error
try {
var httpResponse = await request.send().timeout(Duration(seconds: 120));
response.statusCode = httpResponse.statusCode;
jsondata = await httpResponse.stream.bytesToString();
response.data = json.decode(jsondata);
// Report a server-side error
if (response.statusCode >= 500) { if (response.statusCode >= 500) {
var data = await response.stream.bytesToString();
sentryReportMessage( sentryReportMessage(
"Server error on file upload", "Server error in uploadFile()",
context: { context: {
"url": _url, "url": url,
"statusCode": "${response.statusCode}", "method": request.method,
"response": response.toString(), "name": name,
"request": request.fields.toString(), "statusCode": response.statusCode.toString(),
"data": data, "requestHeaders": request.headers.toString(),
"responseHeaders": httpResponse.headers.toString(),
} }
); );
} }
} on SocketException catch (error) {
showServerError(L10().connectionRefused, error.toString());
response.error = "SocketException";
response.errorDetail = error.toString();
} on FormatException {
showServerError(
L10().formatException,
L10().formatExceptionJson + ":\n${jsondata}"
);
sentryReportMessage(
"Error decoding JSON response from server",
context: {
"url": url,
"statusCode": response.statusCode.toString(),
"data": jsondata,
}
);
} on TimeoutException {
showTimeoutError();
response.error = "TimeoutException";
} catch (error, stackTrace) {
showServerError(L10().serverError, error.toString());
sentryReportError(error, stackTrace);
response.error = "UnknownError";
response.errorDetail = error.toString();
} }
return response; return response;
@ -827,9 +861,6 @@ class InvenTreeAPI {
return data ?? {}; return data ?? {};
} on FormatException { } on FormatException {
print("JSON format exception!");
print("${body}");
sentryReportMessage( sentryReportMessage(
"Error decoding JSON response from server", "Error decoding JSON response from server",
context: { context: {

View File

@ -1,4 +1,5 @@
import 'dart:ui'; import 'dart:ui';
import 'dart:io';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:dropdown_search/dropdown_search.dart'; import 'package:dropdown_search/dropdown_search.dart';
@ -6,6 +7,7 @@ import 'package:dropdown_search/dropdown_search.dart';
import 'package:inventree/api.dart'; import 'package:inventree/api.dart';
import 'package:inventree/app_colors.dart'; import 'package:inventree/app_colors.dart';
import 'package:inventree/inventree/part.dart'; import 'package:inventree/inventree/part.dart';
import 'package:inventree/inventree/sentry.dart';
import 'package:inventree/inventree/stock.dart'; import 'package:inventree/inventree/stock.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';
@ -26,6 +28,9 @@ class APIFormField {
// Constructor // Constructor
APIFormField(this.name, this.data); APIFormField(this.name, this.data);
// File to be uploaded for this filed
File? attachedfile;
// Name of this field // Name of this field
final String name; final String name;
@ -139,7 +144,7 @@ class APIFormField {
params: filters, params: filters,
); );
if (response.isValid()) { if (response.successful()) {
initial_data = response.data; initial_data = response.data;
} }
} }
@ -159,6 +164,9 @@ class APIFormField {
return _constructFloatField(); return _constructFloatField();
case "choice": case "choice":
return _constructChoiceField(); return _constructChoiceField();
case "file upload":
case "image upload":
return _constructFileField();
default: default:
return ListTile( return ListTile(
title: Text( title: Text(
@ -171,6 +179,44 @@ class APIFormField {
} }
} }
// Field for selecting and uploading files
Widget _constructFileField() {
TextEditingController controller = new TextEditingController();
controller.text = (attachedfile?.path ?? L10().attachmentSelect).split("/").last;
return InputDecorator(
decoration: InputDecoration(
labelText: label,
labelStyle: TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
),
child: ListTile(
title: TextField(
readOnly: true,
controller: controller,
),
trailing: IconButton(
icon: FaIcon(FontAwesomeIcons.plusCircle),
onPressed: () async {
FilePickerDialog.pickFile(
message: L10().attachmentSelect,
onPicked: (file) {
print("${file.path}");
// Display the filename
controller.text = file.path.split("/").last;
// Save the file
attachedfile = file;
}
);
},
)
)
);
}
// Field for selecting from multiple choice options
Widget _constructChoiceField() { Widget _constructChoiceField() {
dynamic _initial; dynamic _initial;
@ -478,7 +524,7 @@ Map<String, dynamic> extractFields(APIResponse response) {
* @param method is the HTTP method to use to send the form data to the server (e.g. POST / PATCH) * @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(Map<String, dynamic>)? onSuccess, Function? onCancel}) async { Future<void> launchApiForm(BuildContext context, String title, String url, Map<String, dynamic> fields, {String fileField = "", Map<String, dynamic> modelData = const {}, String method = "PATCH", Function(Map<String, dynamic>)? onSuccess, Function? onCancel}) async {
var options = await InvenTreeAPI().options(url); var options = await InvenTreeAPI().options(url);
@ -508,6 +554,14 @@ Future<void> launchApiForm(BuildContext context, String title, String url, Map<S
// Check that the field is actually available at the API endpoint // Check that the field is actually available at the API endpoint
if (!availableFields.containsKey(fieldName)) { if (!availableFields.containsKey(fieldName)) {
print("Field '${fieldName}' not available at '${url}'"); print("Field '${fieldName}' not available at '${url}'");
sentryReportMessage(
"API form called with unknown field '${fieldName}'",
context: {
"url": url.toString(),
}
);
continue; continue;
} }
@ -563,6 +617,7 @@ Future<void> launchApiForm(BuildContext context, String title, String url, Map<S
formFields, formFields,
method, method,
onSuccess: onSuccess, onSuccess: onSuccess,
fileField: fileField,
)) ))
); );
} }
@ -579,6 +634,8 @@ class APIFormWidget extends StatefulWidget {
//! API method //! API method
final String method; final String method;
final String fileField;
final List<APIFormField> fields; final List<APIFormField> fields;
Function(Map<String, dynamic>)? onSuccess; Function(Map<String, dynamic>)? onSuccess;
@ -591,11 +648,12 @@ class APIFormWidget extends StatefulWidget {
{ {
Key? key, Key? key,
this.onSuccess, this.onSuccess,
this.fileField = "",
} }
) : super(key: key); ) : super(key: key);
@override @override
_APIFormWidgetState createState() => _APIFormWidgetState(title, url, fields, method, onSuccess); _APIFormWidgetState createState() => _APIFormWidgetState(title, url, fields, method, onSuccess, fileField);
} }
@ -610,11 +668,13 @@ class _APIFormWidgetState extends State<APIFormWidget> {
String method; String method;
String fileField;
List<APIFormField> fields; List<APIFormField> fields;
Function(Map<String, dynamic>)? onSuccess; Function(Map<String, dynamic>)? onSuccess;
_APIFormWidgetState(this.title, this.url, this.fields, this.method, this.onSuccess) : super(); _APIFormWidgetState(this.title, this.url, this.fields, this.method, this.onSuccess, this.fileField) : super();
bool spacerRequired = false; bool spacerRequired = false;
@ -678,6 +738,32 @@ class _APIFormWidgetState extends State<APIFormWidget> {
Future<APIResponse> _submit(Map<String, String> data) async { Future<APIResponse> _submit(Map<String, String> data) async {
// If a file upload is required, we have to handle the submission differently
if (fileField.isNotEmpty) {
// Pop the "file" field
data.remove(fileField);
for (var field in fields) {
if (field.name == fileField) {
File? file = field.attachedfile;
if (file != null) {
// A valid file has been supplied
return await InvenTreeAPI().uploadFile(
url,
file,
name: fileField,
fields: data,
);
}
}
}
}
if (method == "POST") { if (method == "POST") {
return await InvenTreeAPI().post( return await InvenTreeAPI().post(
url, url,

View File

@ -73,7 +73,7 @@ class InvenTreeModel {
return {}; return {};
} }
Future<void> createForm(BuildContext context, String title, {Map<String, dynamic> fields=const{}, Map<String, dynamic> data=const {}, Function(dynamic)? onSuccess}) async { Future<void> createForm(BuildContext context, String title, {String fileField = "", Map<String, dynamic> fields=const{}, Map<String, dynamic> data=const {}, Function(dynamic)? onSuccess}) async {
if (fields.isEmpty) { if (fields.isEmpty) {
fields = formFields(); fields = formFields();
@ -87,6 +87,7 @@ class InvenTreeModel {
modelData: data, modelData: data,
onSuccess: onSuccess, onSuccess: onSuccess,
method: "POST", method: "POST",
fileField: fileField,
); );
} }
@ -511,7 +512,7 @@ class InvenTreeAttachment extends InvenTreeModel {
Future<bool> uploadAttachment(File attachment, {String comment = "", Map<String, String> fields = const {}}) async { Future<bool> uploadAttachment(File attachment, {String comment = "", Map<String, String> fields = const {}}) async {
final http.StreamedResponse response = await InvenTreeAPI().uploadFile( final APIResponse response = await InvenTreeAPI().uploadFile(
URL, URL,
attachment, attachment,
method: 'POST', method: 'POST',
@ -519,11 +520,7 @@ class InvenTreeAttachment extends InvenTreeModel {
fields: fields fields: fields
); );
if (response.statusCode == 200 || response.statusCode == 201) { return response.successful();
return true;
} else {
return false;
}
} }
Future<void> downloadAttachment() async { Future<void> downloadAttachment() async {

View File

@ -356,19 +356,14 @@ class InvenTreePart extends InvenTreeModel {
Future<bool> uploadImage(File image) async { Future<bool> uploadImage(File image) async {
// Upload file against this part // Upload file against this part
final http.StreamedResponse response = await InvenTreeAPI().uploadFile( final APIResponse response = await InvenTreeAPI().uploadFile(
url, url,
image, image,
method: 'PATCH', method: 'PATCH',
name: 'image', name: 'image',
); );
if (response.statusCode != 200) { return response.successful();
print("uploadImage returned ${response.statusCode} at '${url}'");
return false;
}
return true;
} }
// Return the "starred" status of this part // Return the "starred" status of this part

View File

@ -17,6 +17,20 @@ class InvenTreeStockItemTestResult extends InvenTreeModel {
@override @override
String get URL => "stock/test/"; String get URL => "stock/test/";
@override
Map<String, dynamic> formFields() {
return {
"stock_item": {
"hidden": true
},
"test": {},
"result": {},
"value": {},
"notes": {},
"attachment": {},
};
}
String get key => jsondata['key'] ?? ''; String get key => jsondata['key'] ?? '';
String get testName => jsondata['test'] ?? ''; String get testName => jsondata['test'] ?? '';
@ -190,39 +204,6 @@ class InvenTreeStockItem extends InvenTreeModel {
}); });
} }
Future<bool> uploadTestResult(BuildContext context, String testName, bool result, {String? value, String? notes, File? attachment}) async {
Map<String, String> data = {
"stock_item": pk.toString(),
"test": testName,
"result": result.toString(),
};
if (value != null && value.isNotEmpty) {
data["value"] = value;
}
if (notes != null && notes.isNotEmpty) {
data["notes"] = notes;
}
/*
* Upload is performed in different ways, depending if an attachment is provided.
* TODO: Is there a nice way to refactor this one?
*/
if (attachment == null) {
var _result = await InvenTreeStockItemTestResult().create(data);
return (_result != null) && (_result is InvenTreeStockItemTestResult);
} else {
var url = InvenTreeStockItemTestResult().URL;
http.StreamedResponse _uploadResponse = await InvenTreeAPI().uploadFile(url, attachment, fields: data);
// Check that the HTTP status code is HTTP_201_CREATED
return _uploadResponse.statusCode == 201;
}
}
String get uid => jsondata['uid'] ?? ''; String get uid => jsondata['uid'] ?? '';
int get status => jsondata['status'] ?? -1; int get status => jsondata['status'] ?? -1;

View File

@ -51,104 +51,14 @@ class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
profile = userProfile; profile = userProfile;
} }
showFormDialog( Navigator.push(
createNew ? L10().profileAdd : L10().profileEdit, context,
key: _addProfileKey, MaterialPageRoute(
callback: () { builder: (context) => ProfileEditWidget(userProfile)
if (createNew) { )
).then((context) {
UserProfile profile = UserProfile( _reload();
name: _name, });
server: _server,
username: _username,
password: _password
);
_addProfile(profile);
} else {
profile?.name = _name;
profile?.server = _server;
profile?.username = _username;
profile?.password = _password;
_updateProfile(profile);
}
},
fields: <Widget> [
StringField(
label: L10().name,
hint: "Enter profile name",
initial: createNew ? '' : profile?.name ?? '',
onSaved: (value) => _name = value,
validator: _validateProfileName,
),
StringField(
label: L10().server,
hint: "http[s]://<server>:<port>",
initial: createNew ? '' : profile?.server ?? '',
validator: _validateServer,
onSaved: (value) => _server = value,
),
StringField(
label: L10().username,
hint: L10().enterPassword,
initial: createNew ? '' : profile?.username ?? '',
onSaved: (value) => _username = value,
validator: _validateUsername,
),
StringField(
label: L10().password,
hint: L10().enterUsername,
initial: createNew ? '' : profile?.password ?? '',
onSaved: (value) => _password = value,
validator: _validatePassword,
)
]
);
}
String? _validateProfileName(String value) {
if (value.isEmpty) {
return 'Profile name cannot be empty';
}
// TODO: Check if a profile already exists with ths name
return null;
}
String? _validateServer(String value) {
if (value.isEmpty) {
return L10().serverEmpty;
}
if (!value.startsWith("http:") && !value.startsWith("https:")) {
return L10().serverStart;
}
// TODO: URL validator
return null;
}
String? _validateUsername(String value) {
if (value.isEmpty) {
return L10().usernameEmpty;
}
return null;
}
String? _validatePassword(String value) {
if (value.isEmpty) {
return L10().passwordEmpty;
}
return null;
} }
void _selectProfile(BuildContext context, UserProfile profile) async { void _selectProfile(BuildContext context, UserProfile profile) async {
@ -191,8 +101,6 @@ class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
return; return;
} }
await UserProfileDBManager().updateProfile(profile);
_reload(); _reload();
if (InvenTreeAPI().isConnected() && InvenTreeAPI().profile != null && profile.key == (InvenTreeAPI().profile?.key ?? '')) { if (InvenTreeAPI().isConnected() && InvenTreeAPI().profile != null && profile.key == (InvenTreeAPI().profile?.key ?? '')) {
@ -204,12 +112,6 @@ class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
} }
} }
void _addProfile(UserProfile profile) async {
await UserProfileDBManager().addProfile(profile);
_reload();
}
Widget? _getProfileIcon(UserProfile profile) { Widget? _getProfileIcon(UserProfile profile) {
@ -275,14 +177,20 @@ class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
Navigator.of(context).pop(); Navigator.of(context).pop();
_selectProfile(context, profile); _selectProfile(context, profile);
}, },
child: Text(L10().profileConnect), child: ListTile(
title: Text(L10().profileConnect),
leading: FaIcon(FontAwesomeIcons.server),
)
), ),
SimpleDialogOption( SimpleDialogOption(
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
_editProfile(context, userProfile: profile); _editProfile(context, userProfile: profile);
}, },
child: Text(L10().profileEdit), child: ListTile(
title: Text(L10().profileEdit),
leading: FaIcon(FontAwesomeIcons.edit)
)
), ),
SimpleDialogOption( SimpleDialogOption(
onPressed: () { onPressed: () {
@ -296,7 +204,10 @@ class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
} }
); );
}, },
child: Text(L10().profileDelete), child: ListTile(
title: Text(L10().profileDelete),
leading: FaIcon(FontAwesomeIcons.trashAlt),
)
) )
], ],
); );
@ -318,6 +229,14 @@ class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
key: _loginKey, key: _loginKey,
appBar: AppBar( appBar: AppBar(
title: Text(L10().profileSelect), title: Text(L10().profileSelect),
actions: [
IconButton(
icon: FaIcon(FontAwesomeIcons.plusCircle),
onPressed: () {
_editProfile(context, createNew: true);
},
)
],
), ),
body: Container( body: Container(
child: ListView( child: ListView(
@ -327,12 +246,185 @@ class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
).toList(), ).toList(),
) )
), ),
floatingActionButton: FloatingActionButton( );
child: Icon(FontAwesomeIcons.plus), }
onPressed: () { }
_editProfile(context, createNew: true);
},
class ProfileEditWidget extends StatefulWidget {
UserProfile? profile;
ProfileEditWidget(this.profile) : super();
@override
_ProfileEditState createState() => _ProfileEditState(profile);
}
class _ProfileEditState extends State<ProfileEditWidget> {
UserProfile? profile;
_ProfileEditState(this.profile) : super();
final formKey = new GlobalKey<FormState>();
String name = "";
String server = "";
String username = "";
String password = "";
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(profile == null ? L10().profileAdd : L10().profileEdit),
actions: [
IconButton(
icon: FaIcon(FontAwesomeIcons.save),
onPressed: () async {
if (formKey.currentState!.validate()) {
formKey.currentState!.save();
UserProfile? prf = profile;
if (prf == null) {
UserProfile profile = UserProfile(
name: name,
server: server,
username: username,
password: password,
);
await UserProfileDBManager().addProfile(profile);
} else {
prf.name = name;
prf.server = server;
prf.username = username;
prf.password = password;
await UserProfileDBManager().updateProfile(prf);
}
// Close the window
Navigator.of(context).pop();
}
},
)
]
),
body: Form(
key: formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
decoration: InputDecoration(
labelText: L10().profileName,
labelStyle: TextStyle(fontWeight: FontWeight.bold),
),
initialValue: profile?.name ?? "",
maxLines: 1,
keyboardType: TextInputType.text,
onSaved: (value) {
name = value?.trim() ?? "";
},
validator: (value) {
if (value == null || value.trim().isEmpty) {
return L10().valueCannotBeEmpty;
}
}
),
TextFormField(
decoration: InputDecoration(
labelText: L10().server,
labelStyle: TextStyle(fontWeight: FontWeight.bold),
hintText: "http[s]://<server>:<port>",
),
initialValue: profile?.server ?? "",
keyboardType: TextInputType.url,
onSaved: (value) {
server = value?.trim() ?? "";
},
validator: (value) {
if (value == null || value.trim().isEmpty) {
return L10().serverEmpty;
}
value = value.trim();
// Spaces are bad
if (value.contains(" ")) {
return L10().invalidHost;
}
if (!value.startsWith("http:") && !value.startsWith("https:")) {
// return L10().serverStart;
}
Uri? _uri = Uri.tryParse(value);
if (_uri == null || _uri.host.isEmpty) {
return L10().invalidHost;
} else {
Uri uri = Uri.parse(value);
if (uri.hasScheme) {
print("Scheme: ${uri.scheme}");
if (!(["http", "https"].contains(uri.scheme.toLowerCase()))) {
return L10().serverStart;
}
} else {
return L10().invalidHost;
}
}
},
),
TextFormField(
decoration: InputDecoration(
labelText: L10().username,
labelStyle: TextStyle(fontWeight: FontWeight.bold),
hintText: L10().enterUsername
),
initialValue: profile?.username ?? "",
keyboardType: TextInputType.text,
onSaved: (value) {
username = value?.trim() ?? "";
},
validator: (value) {
if (value == null || value.trim().isEmpty) {
return L10().usernameEmpty;
}
},
),
TextFormField(
decoration: InputDecoration(
labelText: L10().password,
labelStyle: TextStyle(fontWeight: FontWeight.bold),
hintText: L10().enterPassword,
),
initialValue: profile?.password ?? "",
keyboardType: TextInputType.visiblePassword,
obscureText: true,
onSaved: (value) {
password = value ?? "";
},
validator: (value) {
if (value == null || value.trim().isEmpty) {
return L10().passwordEmpty;
}
}
)
]
),
padding: EdgeInsets.all(16),
),
) )
); );
} }
} }

View File

@ -202,7 +202,10 @@ class _CategoryDisplayState extends RefreshableState<CategoryDisplayWidget> {
} else if (_subcategories.length == 0) { } else if (_subcategories.length == 0) {
tiles.add(ListTile( tiles.add(ListTile(
title: Text(L10().noSubcategories), title: Text(L10().noSubcategories),
subtitle: Text(L10().noSubcategoriesAvailable) subtitle: Text(
L10().noSubcategoriesAvailable,
style: TextStyle(fontStyle: FontStyle.italic)
)
)); ));
} else { } else {
tiles.add(SubcategoryList(_subcategories)); tiles.add(SubcategoryList(_subcategories));

View File

@ -1,92 +1,144 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.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';
import 'dart:io'; import 'dart:io';
// TODO - Perhaps refactor all this using flutter_form_builder - https://pub.dev/packages/flutter_form_builder import 'package:one_context/one_context.dart';
/*
* Form field for selecting an image file,
* either from the gallery, or from the camera.
*/
class ImagePickerField extends FormField<File> {
static void _selectFromGallery(FormFieldState<File> field) { class FilePickerDialog {
_getImageFromGallery(field);
}
static void _selectFromCamera(FormFieldState<File> field) { static Future<File?> pickImageFromCamera() async {
_getImageFromCamera(field);
}
static Future<void> _getImageFromGallery(FormFieldState<File> field) async {
final picker = ImagePicker(); final picker = ImagePicker();
final pickedImage = await picker.getImage(source: ImageSource.gallery); final pickedImage = await picker.pickImage(source: ImageSource.camera);
if (pickedImage != null) if (pickedImage != null) {
{ return File(pickedImage.path);
field.didChange(File(pickedImage.path));
} }
return null;
} }
static Future<void> _getImageFromCamera(FormFieldState<File> field) async { static Future<File?> pickImageFromGallery() async {
final picker = ImagePicker(); final picker = ImagePicker();
final pickedImage = await picker.getImage(source: ImageSource.camera); final pickedImage = await picker.pickImage(source: ImageSource.gallery);
if (pickedImage != null) if (pickedImage != null) {
{ return File(pickedImage.path);
field.didChange(File(pickedImage.path));
} }
return null;
} }
ImagePickerField(BuildContext context, {String? label, Function(File?)? onSaved, bool required = false}) : static Future<File?> pickFileFromDevice() async {
super(
onSaved: onSaved, final FilePickerResult? result = await FilePicker.platform.pickFiles();
validator: (File? img) {
if (required && (img == null)) { if (result != null) {
return L10().required; String? path = result.files.single.path;
if (path != null) {
return File(path);
}
return null;
}
}
// Present a dialog to pick a file, either from local file system or from camera
static Future<void> pickFile({String message = "", bool allowImages = true, bool allowFiles = true, Function(File)? onPicked}) async {
String title = "";
if (allowImages && !allowFiles) {
title = L10().selectImage;
} else {
title = L10().selectFile;
}
// Construct actions
List<Widget> actions = [
];
if (message.isNotEmpty) {
actions.add(
ListTile(
title: Text(message)
)
);
}
actions.add(
SimpleDialogOption(
child: ListTile(
leading: FaIcon(FontAwesomeIcons.fileUpload),
title: Text(allowFiles ? L10().selectFile : L10().selectImage),
),
onPressed: () async {
// Close the dialog
OneContext().popDialog();
File? file;
if (allowFiles) {
file = await pickFileFromDevice();
} else {
file = await pickImageFromGallery();
} }
return null; if (file != null) {
if (onPicked != null) {
onPicked(file);
}
}
}, },
builder: (FormFieldState<File> state) { )
);
String _label = label ?? L10().attachImage; if (allowImages) {
actions.add(
SimpleDialogOption(
child: ListTile(
leading: FaIcon(FontAwesomeIcons.camera),
title: Text(L10().takePicture),
),
onPressed: () async {
// Close the dialog
OneContext().popDialog();
return InputDecorator( File? file = await pickImageFromCamera();
decoration: InputDecoration(
errorText: state.errorText, if (file != null) {
labelText: required ? _label + "*" : _label, if (onPicked != null) {
), onPicked(file);
child: Row( }
mainAxisAlignment: MainAxisAlignment.spaceEvenly, }
children: <Widget>[ }
TextButton( )
child: Text(L10().selectImage),
onPressed: () {
_selectFromGallery(state);
},
),
TextButton(
child: Text(L10().takePicture),
onPressed: () {
_selectFromCamera(state);
},
)
],
),
);
}
); );
}
OneContext().showDialog(
builder: (context) {
return SimpleDialog(
title: Text(title),
children: actions,
);
}
);
}
} }

View File

@ -6,6 +6,7 @@ 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:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:inventree/inventree/part.dart'; import 'package:inventree/inventree/part.dart';
import 'package:inventree/widget/fields.dart';
import 'package:inventree/widget/refreshable_state.dart'; import 'package:inventree/widget/refreshable_state.dart';
import 'package:inventree/widget/snacks.dart'; import 'package:inventree/widget/snacks.dart';
@ -46,16 +47,14 @@ class _PartAttachmentDisplayState extends RefreshableState<PartAttachmentsWidget
// File upload // File upload
actions.add( actions.add(
IconButton( IconButton(
icon: FaIcon(FontAwesomeIcons.fileUpload), icon: FaIcon(FontAwesomeIcons.plusCircle),
onPressed: uploadFile, onPressed: () async {
) FilePickerDialog.pickFile(
); onPicked: (File file) {
upload(file);
// Upload from camera }
actions.add( );
IconButton( },
icon: FaIcon(FontAwesomeIcons.camera),
onPressed: uploadFromCamera,
) )
); );
} }
@ -77,45 +76,6 @@ class _PartAttachmentDisplayState extends RefreshableState<PartAttachmentsWidget
showSnackIcon(L10().uploadFailed, success: false); showSnackIcon(L10().uploadFailed, success: false);
} }
refresh();
}
/*
* Select a file from the device to upload
*/
Future<void> uploadFile() async {
final FilePickerResult? result = await FilePicker.platform.pickFiles();
if (result != null) {
String? path = result.files.single.path;
if (path != null) {
File attachment = File(path);
upload(attachment);
}
}
}
/*
* Upload an attachment by taking a new picture with the built in device camera
*/
Future<void> uploadFromCamera() async {
final picker = ImagePicker();
final pickedImage = await picker.getImage(source: ImageSource.camera);
if (pickedImage != null) {
File? attachment = File(pickedImage.path);
upload(attachment);
}
refresh(); refresh();
} }

View File

@ -9,6 +9,7 @@ import 'package:inventree/api.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:inventree/inventree/part.dart'; import 'package:inventree/inventree/part.dart';
import 'package:inventree/widget/fields.dart';
import 'package:inventree/widget/refreshable_state.dart'; import 'package:inventree/widget/refreshable_state.dart';
import 'package:inventree/widget/snacks.dart'; import 'package:inventree/widget/snacks.dart';
@ -37,45 +38,6 @@ class _PartImageState extends RefreshableState<PartImageWidget> {
await part.reload(); await part.reload();
} }
void uploadFromGallery() async {
final picker = ImagePicker();
final pickedImage = await picker.getImage(source: ImageSource.gallery);
if (pickedImage != null) {
File? img = File(pickedImage.path);
final result = await part.uploadImage(img);
if (!result) {
showSnackIcon(L10().uploadFailed, success: false);
}
refresh();
}
}
void uploadFromCamera() async {
final picker = ImagePicker();
final pickedImage = await picker.getImage(source: ImageSource.camera);
if (pickedImage != null) {
File? img = File(pickedImage.path);
final result = await part.uploadImage(img);
if (!result) {
showSnackIcon(L10().uploadFailed, success: false);
}
refresh();
}
}
@override @override
String getAppBarTitle(BuildContext context) => part.fullname; String getAppBarTitle(BuildContext context) => part.fullname;
@ -89,16 +51,22 @@ class _PartImageState extends RefreshableState<PartImageWidget> {
// File upload // File upload
actions.add( actions.add(
IconButton( IconButton(
icon: FaIcon(FontAwesomeIcons.fileImage), icon: FaIcon(FontAwesomeIcons.fileUpload),
onPressed: uploadFromGallery, onPressed: () async {
)
);
// Camera upload FilePickerDialog.pickFile(
actions.add( onPicked: (File file) async {
IconButton( final result = await part.uploadImage(file);
icon: FaIcon(FontAwesomeIcons.camera),
onPressed: uploadFromCamera, if (!result) {
showSnackIcon(L10().uploadFailed, success: false);
}
refresh();
}
);
},
) )
); );
} }

View File

@ -97,7 +97,7 @@ abstract class RefreshableState<T extends StatefulWidget> extends State<T> {
return Scaffold( return Scaffold(
key: refreshableKey, key: refreshableKey,
appBar: getAppBar(context), appBar: getAppBar(context),
drawer: null, // getDrawer(context), drawer: null,
floatingActionButton: getFab(context), floatingActionButton: getFab(context),
body: Builder( body: Builder(
builder: (BuildContext context) { builder: (BuildContext context) {

View File

@ -1,3 +1,4 @@
import 'package:inventree/api_form.dart';
import 'package:inventree/app_colors.dart'; 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';
@ -36,6 +37,18 @@ class _StockItemTestResultDisplayState extends RefreshableState<StockItemTestRes
@override @override
String getAppBarTitle(BuildContext context) => L10().testResults; String getAppBarTitle(BuildContext context) => L10().testResults;
@override
List<Widget> getAppBarActions(BuildContext context) {
return [
IconButton(
icon: FaIcon(FontAwesomeIcons.plusCircle),
onPressed: () {
addTestResult(context);
}
),
];
}
@override @override
Future<void> request() async { Future<void> request() async {
await item.getTestTemplates(); await item.getTestTemplates();
@ -46,73 +59,19 @@ class _StockItemTestResultDisplayState extends RefreshableState<StockItemTestRes
_StockItemTestResultDisplayState(this.item); _StockItemTestResultDisplayState(this.item);
void uploadTestResult(String name, bool result, String value, String notes, File? attachment) async { void addTestResult(BuildContext context, {String name = '', bool nameIsEditable = true, bool result = false, String value = '', bool valueRequired = false, bool attachmentRequired = false}) async {
final success = await item.uploadTestResult( InvenTreeStockItemTestResult().createForm(
context, name, result, context,
value: value, L10().testResultAdd,
notes: notes, data: {
attachment: attachment "stock_item": "${item.pk}",
); "test": "${name}",
showSnackIcon(
success ? L10().testResultUploadPass : L10().testResultUploadFail,
success: success
);
refresh();
}
void addTestResult({String name = '', bool nameIsEditable = true, bool result = false, String value = '', bool valueRequired = false, bool attachmentRequired = false}) async {
String _name = "";
bool _result = false;
String _value = "";
String _notes = "";
File? _attachment;
showFormDialog(L10().testResultAdd,
key: _addResultKey,
callback: () {
uploadTestResult(_name, _result, _value, _notes, _attachment);
}, },
fields: <Widget>[ onSuccess: (data) {
StringField( refresh();
label: L10().testName, },
initial: name, fileField: "attachment",
isEnabled: nameIsEditable,
onSaved: (value) => _name = value ?? '',
),
CheckBoxField(
label: L10().result,
helperText: L10().testPassedOrFailed,
initial: true,
onSaved: (value) => _result = value ?? false,
),
StringField(
label: L10().value,
initial: value,
allowEmpty: true,
onSaved: (value) => _value = value ?? '',
validator: (String value) {
if (valueRequired && value.isEmpty) {
return L10().valueRequired;
}
return null;
},
),
ImagePickerField(
context,
label: L10().attachImage,
required: attachmentRequired,
onSaved: (attachment) => _attachment = attachment,
),
StringField(
allowEmpty: true,
label: L10().notes,
onSaved: (value) => _notes = value ?? '',
),
]
); );
} }
@ -244,6 +203,7 @@ class _StockItemTestResultDisplayState extends RefreshableState<StockItemTestRes
trailing: _icon, trailing: _icon,
onLongPress: () { onLongPress: () {
addTestResult( addTestResult(
context,
name: _test, name: _test,
nameIsEditable: !_required, nameIsEditable: !_required,
valueRequired: _valueRequired, valueRequired: _valueRequired,
@ -272,31 +232,4 @@ class _StockItemTestResultDisplayState extends RefreshableState<StockItemTestRes
).toList() ).toList()
); );
} }
/*
List<SpeedDialChild> actionButtons() {
var buttons = List<SpeedDialChild>();
buttons.add(SpeedDialChild(
child: Icon(FontAwesomeIcons.plusCircle),
label: L10().testResultAdd,
onTap: () {
addTestResult();
},
));
return buttons;
}
*/
@override
Widget getFab(BuildContext context) {
return FloatingActionButton(
child: Icon(FontAwesomeIcons.plus),
onPressed: () {
addTestResult();
},
);
}
} }

View File

@ -235,13 +235,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.0.2" version: "2.0.2"
flutter_speed_dial:
dependency: "direct main"
description:
name: flutter_speed_dial
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.5"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
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.4.5+26 version: 0.4.6+27
environment: environment:
sdk: ">=2.12.0 <3.0.0" sdk: ">=2.12.0 <3.0.0"
@ -28,7 +28,6 @@ dependencies:
package_info_plus: ^1.0.4 # App information introspection package_info_plus: ^1.0.4 # App information introspection
device_info_plus: ^2.1.0 # Information about the device device_info_plus: ^2.1.0 # Information about the device
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
sentry_flutter: 5.0.0 # Error reporting sentry_flutter: 5.0.0 # Error reporting
image_picker: ^0.8.3 # Select or take photos image_picker: ^0.8.3 # Select or take photos
file_picker: ^4.0.0 # Select files from the device file_picker: ^4.0.0 # Select files from the device