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
---
### 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
---

View File

@ -42,6 +42,14 @@ class APIResponse {
// Request is "valid" if a statusCode was returned
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 was invalid for some reason
if (!response.isValid()) {
return false;
}
if (response.statusCode != 200) {
if (!response.successful()) {
showStatusCodeError(response.statusCode);
return false;
}
@ -308,16 +311,10 @@ class InvenTreeAPI {
// Clear the existing token value
_token = "";
print("Requesting token from server");
response = await get(_URL_GET_TOKEN);
// Invalid response
if (!response.isValid()) {
return false;
}
if (response.statusCode != 200) {
if (!response.successful()) {
switch (response.statusCode) {
case 401:
@ -413,7 +410,7 @@ class InvenTreeAPI {
var response = await get(_URL_GET_ROLES, expectedStatusCode: 200);
if (!response.isValid() || response.statusCode != 200) {
if (!response.successful()) {
return;
}
@ -563,9 +560,8 @@ class InvenTreeAPI {
/*
* 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 {
var _url = makeApiUrl(url);
var request = http.MultipartRequest(method, Uri.parse(_url));
@ -582,25 +578,63 @@ class InvenTreeAPI {
request.files.add(_file);
var response = await request.send();
APIResponse response = APIResponse(
url: url,
method: method,
);
String jsondata = "";
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) {
// Server error
if (response.statusCode >= 500) {
var data = await response.stream.bytesToString();
sentryReportMessage(
"Server error on file upload",
"Server error in uploadFile()",
context: {
"url": _url,
"statusCode": "${response.statusCode}",
"response": response.toString(),
"request": request.fields.toString(),
"data": data,
"url": url,
"method": request.method,
"name": name,
"statusCode": response.statusCode.toString(),
"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;
@ -827,9 +861,6 @@ class InvenTreeAPI {
return data ?? {};
} on FormatException {
print("JSON format exception!");
print("${body}");
sentryReportMessage(
"Error decoding JSON response from server",
context: {

View File

@ -1,4 +1,5 @@
import 'dart:ui';
import 'dart:io';
import 'package:font_awesome_flutter/font_awesome_flutter.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/app_colors.dart';
import 'package:inventree/inventree/part.dart';
import 'package:inventree/inventree/sentry.dart';
import 'package:inventree/inventree/stock.dart';
import 'package:inventree/widget/dialogs.dart';
import 'package:inventree/widget/fields.dart';
@ -26,6 +28,9 @@ class APIFormField {
// Constructor
APIFormField(this.name, this.data);
// File to be uploaded for this filed
File? attachedfile;
// Name of this field
final String name;
@ -139,7 +144,7 @@ class APIFormField {
params: filters,
);
if (response.isValid()) {
if (response.successful()) {
initial_data = response.data;
}
}
@ -159,6 +164,9 @@ class APIFormField {
return _constructFloatField();
case "choice":
return _constructChoiceField();
case "file upload":
case "image upload":
return _constructFileField();
default:
return ListTile(
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() {
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)
*/
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);
@ -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
if (!availableFields.containsKey(fieldName)) {
print("Field '${fieldName}' not available at '${url}'");
sentryReportMessage(
"API form called with unknown field '${fieldName}'",
context: {
"url": url.toString(),
}
);
continue;
}
@ -563,6 +617,7 @@ Future<void> launchApiForm(BuildContext context, String title, String url, Map<S
formFields,
method,
onSuccess: onSuccess,
fileField: fileField,
))
);
}
@ -579,6 +634,8 @@ class APIFormWidget extends StatefulWidget {
//! API method
final String method;
final String fileField;
final List<APIFormField> fields;
Function(Map<String, dynamic>)? onSuccess;
@ -591,11 +648,12 @@ class APIFormWidget extends StatefulWidget {
{
Key? key,
this.onSuccess,
this.fileField = "",
}
) : super(key: key);
@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 fileField;
List<APIFormField> fields;
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;
@ -678,6 +738,32 @@ class _APIFormWidgetState extends State<APIFormWidget> {
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") {
return await InvenTreeAPI().post(
url,

View File

@ -73,7 +73,7 @@ class InvenTreeModel {
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) {
fields = formFields();
@ -87,6 +87,7 @@ class InvenTreeModel {
modelData: data,
onSuccess: onSuccess,
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 {
final http.StreamedResponse response = await InvenTreeAPI().uploadFile(
final APIResponse response = await InvenTreeAPI().uploadFile(
URL,
attachment,
method: 'POST',
@ -519,11 +520,7 @@ class InvenTreeAttachment extends InvenTreeModel {
fields: fields
);
if (response.statusCode == 200 || response.statusCode == 201) {
return true;
} else {
return false;
}
return response.successful();
}
Future<void> downloadAttachment() async {

View File

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

View File

@ -17,6 +17,20 @@ class InvenTreeStockItemTestResult extends InvenTreeModel {
@override
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 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'] ?? '';
int get status => jsondata['status'] ?? -1;

View File

@ -51,104 +51,14 @@ class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
profile = userProfile;
}
showFormDialog(
createNew ? L10().profileAdd : L10().profileEdit,
key: _addProfileKey,
callback: () {
if (createNew) {
UserProfile profile = UserProfile(
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,
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ProfileEditWidget(userProfile)
)
]
);
}
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;
).then((context) {
_reload();
});
}
void _selectProfile(BuildContext context, UserProfile profile) async {
@ -191,8 +101,6 @@ class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
return;
}
await UserProfileDBManager().updateProfile(profile);
_reload();
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) {
@ -275,14 +177,20 @@ class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
Navigator.of(context).pop();
_selectProfile(context, profile);
},
child: Text(L10().profileConnect),
child: ListTile(
title: Text(L10().profileConnect),
leading: FaIcon(FontAwesomeIcons.server),
)
),
SimpleDialogOption(
onPressed: () {
Navigator.of(context).pop();
_editProfile(context, userProfile: profile);
},
child: Text(L10().profileEdit),
child: ListTile(
title: Text(L10().profileEdit),
leading: FaIcon(FontAwesomeIcons.edit)
)
),
SimpleDialogOption(
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,
appBar: AppBar(
title: Text(L10().profileSelect),
actions: [
IconButton(
icon: FaIcon(FontAwesomeIcons.plusCircle),
onPressed: () {
_editProfile(context, createNew: true);
},
)
],
),
body: Container(
child: ListView(
@ -327,12 +246,185 @@ class _InvenTreeLoginSettingsState extends State<InvenTreeLoginSettingsWidget> {
).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) {
tiles.add(ListTile(
title: Text(L10().noSubcategories),
subtitle: Text(L10().noSubcategoriesAvailable)
subtitle: Text(
L10().noSubcategoriesAvailable,
style: TextStyle(fontStyle: FontStyle.italic)
)
));
} else {
tiles.add(SubcategoryList(_subcategories));

View File

@ -1,94 +1,146 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:image_picker/image_picker.dart';
import 'package:inventree/l10.dart';
import 'dart:async';
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) {
_getImageFromGallery(field);
}
class FilePickerDialog {
static void _selectFromCamera(FormFieldState<File> field) {
_getImageFromCamera(field);
}
static Future<void> _getImageFromGallery(FormFieldState<File> field) async {
static Future<File?> pickImageFromCamera() async {
final picker = ImagePicker();
final pickedImage = await picker.getImage(source: ImageSource.gallery);
final pickedImage = await picker.pickImage(source: ImageSource.camera);
if (pickedImage != null)
{
field.didChange(File(pickedImage.path));
}
}
static Future<void> _getImageFromCamera(FormFieldState<File> field) async {
final picker = ImagePicker();
final pickedImage = await picker.getImage(source: ImageSource.camera);
if (pickedImage != null)
{
field.didChange(File(pickedImage.path));
}
}
ImagePickerField(BuildContext context, {String? label, Function(File?)? onSaved, bool required = false}) :
super(
onSaved: onSaved,
validator: (File? img) {
if (required && (img == null)) {
return L10().required;
if (pickedImage != null) {
return File(pickedImage.path);
}
return null;
},
builder: (FormFieldState<File> state) {
}
String _label = label ?? L10().attachImage;
static Future<File?> pickImageFromGallery() async {
return InputDecorator(
decoration: InputDecoration(
errorText: state.errorText,
labelText: required ? _label + "*" : _label,
final picker = ImagePicker();
final pickedImage = await picker.pickImage(source: ImageSource.gallery);
if (pickedImage != null) {
return File(pickedImage.path);
}
return null;
}
static Future<File?> pickFileFromDevice() async {
final FilePickerResult? result = await FilePicker.platform.pickFiles();
if (result != null) {
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),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
TextButton(
child: Text(L10().selectImage),
onPressed: () {
_selectFromGallery(state);
},
),
TextButton(
child: Text(L10().takePicture),
onPressed: () {
_selectFromCamera(state);
onPressed: () async {
// Close the dialog
OneContext().popDialog();
File? file;
if (allowFiles) {
file = await pickFileFromDevice();
} else {
file = await pickImageFromGallery();
}
if (file != null) {
if (onPicked != null) {
onPicked(file);
}
}
},
)
],
);
if (allowImages) {
actions.add(
SimpleDialogOption(
child: ListTile(
leading: FaIcon(FontAwesomeIcons.camera),
title: Text(L10().takePicture),
),
onPressed: () async {
// Close the dialog
OneContext().popDialog();
File? file = await pickImageFromCamera();
if (file != null) {
if (onPicked != null) {
onPicked(file);
}
}
}
)
);
}
OneContext().showDialog(
builder: (context) {
return SimpleDialog(
title: Text(title),
children: actions,
);
}
);
}
}
class CheckBoxField extends FormField<bool> {
CheckBoxField({

View File

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:image_picker/image_picker.dart';
import 'package:inventree/inventree/part.dart';
import 'package:inventree/widget/fields.dart';
import 'package:inventree/widget/refreshable_state.dart';
import 'package:inventree/widget/snacks.dart';
@ -46,16 +47,14 @@ class _PartAttachmentDisplayState extends RefreshableState<PartAttachmentsWidget
// File upload
actions.add(
IconButton(
icon: FaIcon(FontAwesomeIcons.fileUpload),
onPressed: uploadFile,
)
icon: FaIcon(FontAwesomeIcons.plusCircle),
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);
}
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();
}

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import 'package:inventree/api_form.dart';
import 'package:inventree/app_colors.dart';
import 'package:inventree/inventree/part.dart';
import 'package:inventree/inventree/stock.dart';
@ -36,6 +37,18 @@ class _StockItemTestResultDisplayState extends RefreshableState<StockItemTestRes
@override
String getAppBarTitle(BuildContext context) => L10().testResults;
@override
List<Widget> getAppBarActions(BuildContext context) {
return [
IconButton(
icon: FaIcon(FontAwesomeIcons.plusCircle),
onPressed: () {
addTestResult(context);
}
),
];
}
@override
Future<void> request() async {
await item.getTestTemplates();
@ -46,73 +59,19 @@ class _StockItemTestResultDisplayState extends RefreshableState<StockItemTestRes
_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(
context, name, result,
value: value,
notes: notes,
attachment: attachment
);
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>[
StringField(
label: L10().testName,
initial: name,
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(
InvenTreeStockItemTestResult().createForm(
context,
label: L10().attachImage,
required: attachmentRequired,
onSaved: (attachment) => _attachment = attachment,
),
StringField(
allowEmpty: true,
label: L10().notes,
onSaved: (value) => _notes = value ?? '',
),
]
L10().testResultAdd,
data: {
"stock_item": "${item.pk}",
"test": "${name}",
},
onSuccess: (data) {
refresh();
},
fileField: "attachment",
);
}
@ -244,6 +203,7 @@ class _StockItemTestResultDisplayState extends RefreshableState<StockItemTestRes
trailing: _icon,
onLongPress: () {
addTestResult(
context,
name: _test,
nameIsEditable: !_required,
valueRequired: _valueRequired,
@ -272,31 +232,4 @@ class _StockItemTestResultDisplayState extends RefreshableState<StockItemTestRes
).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"
source: hosted
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:
dependency: "direct dev"
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.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 0.4.5+26
version: 0.4.6+27
environment:
sdk: ">=2.12.0 <3.0.0"
@ -28,7 +28,6 @@ dependencies:
package_info_plus: ^1.0.4 # App information introspection
device_info_plus: ^2.1.0 # Information about the device
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
image_picker: ^0.8.3 # Select or take photos
file_picker: ^4.0.0 # Select files from the device