From 638848092bab980a6362301b971b8078ee1d4246 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 16 Aug 2021 16:28:11 +1000 Subject: [PATCH 01/11] Refactor profile selection screen - Launch a new window, instead of a modal - Improved host validation - Better keyboard inputs --- assets/release_notes.md | 6 + lib/l10n | 2 +- lib/settings/login.dart | 293 ++++++++++++++++++++++++++-------------- 3 files changed, 195 insertions(+), 106 deletions(-) diff --git a/assets/release_notes.md b/assets/release_notes.md index 41e98b2e..db6b20b5 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -1,6 +1,12 @@ ## InvenTree App Release Notes --- +### 0.4.6 - August 2021 +--- + +- Improved profile selection screen +- Fixed a number of incorrect labels + ### 0.4.5 - August 2021 --- diff --git a/lib/l10n b/lib/l10n index 951063e0..48ed29ca 160000 --- a/lib/l10n +++ b/lib/l10n @@ -1 +1 @@ -Subproject commit 951063e039d4659159f42ec6425092f4f3bb808c +Subproject commit 48ed29ca98d35585721e6af5718d7dcb08869705 diff --git a/lib/settings/login.dart b/lib/settings/login.dart index 5112161a..13db320e 100644 --- a/lib/settings/login.dart +++ b/lib/settings/login.dart @@ -51,104 +51,14 @@ class _InvenTreeLoginSettingsState extends State { 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: [ - 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]://:", - 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; + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ProfileEditWidget(userProfile) + ) + ).then((context) { + _reload(); + }); } void _selectProfile(BuildContext context, UserProfile profile) async { @@ -191,7 +101,7 @@ class _InvenTreeLoginSettingsState extends State { return; } - await UserProfileDBManager().updateProfile(profile); + _reload(); @@ -204,12 +114,6 @@ class _InvenTreeLoginSettingsState extends State { } } - void _addProfile(UserProfile profile) async { - - await UserProfileDBManager().addProfile(profile); - - _reload(); - } Widget? _getProfileIcon(UserProfile profile) { @@ -335,4 +239,183 @@ class _InvenTreeLoginSettingsState extends State { ) ); } +} + + +class ProfileEditWidget extends StatefulWidget { + + UserProfile? profile; + + ProfileEditWidget(this.profile) : super(); + + @override + _ProfileEditState createState() => _ProfileEditState(profile); +} + +class _ProfileEditState extends State { + + UserProfile? profile; + + _ProfileEditState(this.profile) : super(); + + final formKey = new GlobalKey(); + + 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]://:", + ), + 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), + ), + ) + ); + } + } \ No newline at end of file From b9c9b8acc18dc1836e176701f1bf55ac871ae6e6 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 16 Aug 2021 16:31:23 +1000 Subject: [PATCH 02/11] Add some icon --- lib/settings/login.dart | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/settings/login.dart b/lib/settings/login.dart index 13db320e..0e9bf5d7 100644 --- a/lib/settings/login.dart +++ b/lib/settings/login.dart @@ -179,14 +179,20 @@ class _InvenTreeLoginSettingsState extends State { 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: () { @@ -200,7 +206,10 @@ class _InvenTreeLoginSettingsState extends State { } ); }, - child: Text(L10().profileDelete), + child: ListTile( + title: Text(L10().profileDelete), + leading: FaIcon(FontAwesomeIcons.trashAlt), + ) ) ], ); From 7317f9cbadd6c9bfd08f2ae2233207880d107180 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 16 Aug 2021 17:47:55 +1000 Subject: [PATCH 03/11] New class for selecting an image --- lib/l10n | 2 +- lib/widget/fields.dart | 125 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/lib/l10n b/lib/l10n index 48ed29ca..fc01d982 160000 --- a/lib/l10n +++ b/lib/l10n @@ -1 +1 @@ -Subproject commit 48ed29ca98d35585721e6af5718d7dcb08869705 +Subproject commit fc01d9826d51e15377894d9cb6347c61b22e3be1 diff --git a/lib/widget/fields.dart b/lib/widget/fields.dart index 75abe85e..42ca8e90 100644 --- a/lib/widget/fields.dart +++ b/lib/widget/fields.dart @@ -1,12 +1,135 @@ +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'; + + + +class FilePickerDialog { + + static Future pickImageFromCamera() async { + + final picker = ImagePicker(); + + final pickedImage = await picker.pickImage(source: ImageSource.camera); + + if (pickedImage != null) { + return File(pickedImage.path); + } + + return null; + } + + static Future pickImageFromGallery() async { + + final picker = ImagePicker(); + + final pickedImage = await picker.pickImage(source: ImageSource.gallery); + + if (pickedImage != null) { + return File(pickedImage.path); + } + + return null; + } + + static Future 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 pickFile({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 actions = []; + + 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(); + } + + 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, + ); + } + ); + } + +} /* From 3f63f1e8a7600e506aa0ff4c2008f14200f8c1d5 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 16 Aug 2021 17:55:14 +1000 Subject: [PATCH 04/11] Refactor part image upload and part attachment upload --- lib/widget/part_attachments_widget.dart | 58 ++++------------------ lib/widget/part_image_widget.dart | 64 +++++++------------------ 2 files changed, 25 insertions(+), 97 deletions(-) diff --git a/lib/widget/part_attachments_widget.dart b/lib/widget/part_attachments_widget.dart index 26e5fe22..99955e64 100644 --- a/lib/widget/part_attachments_widget.dart +++ b/lib/widget/part_attachments_widget.dart @@ -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 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 uploadFromCamera() async { - - - final picker = ImagePicker(); - - final pickedImage = await picker.getImage(source: ImageSource.camera); - - if (pickedImage != null) { - File? attachment = File(pickedImage.path); - upload(attachment); - } - refresh(); } diff --git a/lib/widget/part_image_widget.dart b/lib/widget/part_image_widget.dart index d1819cc1..533072a4 100644 --- a/lib/widget/part_image_widget.dart +++ b/lib/widget/part_image_widget.dart @@ -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 { 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 { // File upload actions.add( IconButton( - icon: FaIcon(FontAwesomeIcons.fileImage), - onPressed: uploadFromGallery, - ) - ); + icon: FaIcon(FontAwesomeIcons.fileUpload), + onPressed: () async { - // Camera upload - actions.add( - IconButton( - icon: FaIcon(FontAwesomeIcons.camera), - onPressed: uploadFromCamera, + FilePickerDialog.pickFile( + onPicked: (File file) async { + final result = await part.uploadImage(file); + + if (!result) { + showSnackIcon(L10().uploadFailed, success: false); + } + + refresh(); + } + ); + + }, ) ); } From e108598557f9babb792cbbcb123241f167662676 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 16 Aug 2021 20:20:46 +1000 Subject: [PATCH 05/11] Refactor "uploadFile" function - Error catching! --- lib/api.dart | 78 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 19 deletions(-) diff --git a/lib/api.dart b/lib/api.dart index e79d181f..7777b1a4 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -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); } @@ -308,8 +316,6 @@ class InvenTreeAPI { // Clear the existing token value _token = ""; - print("Requesting token from server"); - response = await get(_URL_GET_TOKEN); // Invalid response @@ -563,9 +569,8 @@ class InvenTreeAPI { /* * Upload a file to the given URL */ - Future uploadFile(String url, File f, + Future uploadFile(String url, File f, {String name = "attachment", String method="POST", Map? fields}) async { - var _url = makeApiUrl(url); var request = http.MultipartRequest(method, Uri.parse(_url)); @@ -582,25 +587,63 @@ class InvenTreeAPI { request.files.add(_file); - var response = await request.send(); + APIResponse response = APIResponse( + url: url, + method: method, + ); - if (response.statusCode >= 500) { - // Server error + 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) { - - 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 +870,6 @@ class InvenTreeAPI { return data ?? {}; } on FormatException { - print("JSON format exception!"); - print("${body}"); - sentryReportMessage( "Error decoding JSON response from server", context: { From 8bca501fc450d8ade5c57abfe7207d5269f16649 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 16 Aug 2021 20:22:05 +1000 Subject: [PATCH 06/11] Allow file fields for api forms --- lib/api_form.dart | 92 ++++++++++++++++++++++++- lib/inventree/model.dart | 11 ++- lib/inventree/part.dart | 9 +-- lib/l10n | 2 +- lib/widget/stock_item_test_results.dart | 1 + 5 files changed, 97 insertions(+), 18 deletions(-) diff --git a/lib/api_form.dart b/lib/api_form.dart index 67b52e6d..51366c13 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -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; @@ -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 extractFields(APIResponse response) { * @param method is the HTTP method to use to send the form data to the server (e.g. POST / PATCH) */ -Future launchApiForm(BuildContext context, String title, String url, Map fields, {Map modelData = const {}, String method = "PATCH", Function(Map)? onSuccess, Function? onCancel}) async { +Future launchApiForm(BuildContext context, String title, String url, Map fields, {String fileField = "", Map modelData = const {}, String method = "PATCH", Function(Map)? onSuccess, Function? onCancel}) async { var options = await InvenTreeAPI().options(url); @@ -508,6 +554,14 @@ Future launchApiForm(BuildContext context, String title, String url, Map launchApiForm(BuildContext context, String title, String url, Map fields; Function(Map)? 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 { String method; + String fileField; + List fields; Function(Map)? 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 { Future _submit(Map 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, diff --git a/lib/inventree/model.dart b/lib/inventree/model.dart index 8f9f3f5b..50ec330f 100644 --- a/lib/inventree/model.dart +++ b/lib/inventree/model.dart @@ -73,7 +73,7 @@ class InvenTreeModel { return {}; } - Future createForm(BuildContext context, String title, {Map fields=const{}, Map data=const {}, Function(dynamic)? onSuccess}) async { + Future createForm(BuildContext context, String title, {String fileField = "", Map fields=const{}, Map 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 uploadAttachment(File attachment, {String comment = "", Map 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 downloadAttachment() async { diff --git a/lib/inventree/part.dart b/lib/inventree/part.dart index dcd18df3..63f11beb 100644 --- a/lib/inventree/part.dart +++ b/lib/inventree/part.dart @@ -356,19 +356,14 @@ class InvenTreePart extends InvenTreeModel { Future 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 diff --git a/lib/l10n b/lib/l10n index fc01d982..da90edf7 160000 --- a/lib/l10n +++ b/lib/l10n @@ -1 +1 @@ -Subproject commit fc01d9826d51e15377894d9cb6347c61b22e3be1 +Subproject commit da90edf7611618212407d12bb8abcf8a66f05364 diff --git a/lib/widget/stock_item_test_results.dart b/lib/widget/stock_item_test_results.dart index fe85175e..bdefdc2b 100644 --- a/lib/widget/stock_item_test_results.dart +++ b/lib/widget/stock_item_test_results.dart @@ -244,6 +244,7 @@ class _StockItemTestResultDisplayState extends RefreshableState Date: Mon, 16 Aug 2021 20:27:44 +1000 Subject: [PATCH 07/11] Refactor upload test result --- lib/inventree/stock.dart | 47 ++++-------- lib/widget/fields.dart | 95 ++++-------------------- lib/widget/stock_item_test_results.dart | 96 ++++--------------------- 3 files changed, 39 insertions(+), 199 deletions(-) diff --git a/lib/inventree/stock.dart b/lib/inventree/stock.dart index c83cba08..9acc7059 100644 --- a/lib/inventree/stock.dart +++ b/lib/inventree/stock.dart @@ -17,6 +17,20 @@ class InvenTreeStockItemTestResult extends InvenTreeModel { @override String get URL => "stock/test/"; + @override + Map 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 uploadTestResult(BuildContext context, String testName, bool result, {String? value, String? notes, File? attachment}) async { - - Map 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; diff --git a/lib/widget/fields.dart b/lib/widget/fields.dart index 42ca8e90..f8016075 100644 --- a/lib/widget/fields.dart +++ b/lib/widget/fields.dart @@ -56,7 +56,7 @@ class FilePickerDialog { } // Present a dialog to pick a file, either from local file system or from camera - static Future pickFile({bool allowImages = true, bool allowFiles = true, Function(File)? onPicked}) async { + static Future pickFile({String message = "", bool allowImages = true, bool allowFiles = true, Function(File)? onPicked}) async { String title = ""; @@ -67,7 +67,17 @@ class FilePickerDialog { } // Construct actions - List actions = []; + List actions = [ + + ]; + + if (message.isNotEmpty) { + actions.add( + ListTile( + title: Text(message) + ) + ); + } actions.add( SimpleDialogOption( @@ -132,87 +142,6 @@ class FilePickerDialog { } -/* - * Form field for selecting an image file, - * either from the gallery, or from the camera. - */ -class ImagePickerField extends FormField { - - static void _selectFromGallery(FormFieldState field) { - _getImageFromGallery(field); - } - - static void _selectFromCamera(FormFieldState field) { - _getImageFromCamera(field); - } - - static Future _getImageFromGallery(FormFieldState field) async { - - final picker = ImagePicker(); - - final pickedImage = await picker.getImage(source: ImageSource.gallery); - - if (pickedImage != null) - { - field.didChange(File(pickedImage.path)); - } - } - - static Future _getImageFromCamera(FormFieldState 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; - } - - return null; - }, - builder: (FormFieldState state) { - - String _label = label ?? L10().attachImage; - - return InputDecorator( - decoration: InputDecoration( - errorText: state.errorText, - labelText: required ? _label + "*" : _label, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - TextButton( - child: Text(L10().selectImage), - onPressed: () { - _selectFromGallery(state); - }, - ), - TextButton( - child: Text(L10().takePicture), - onPressed: () { - _selectFromCamera(state); - }, - ) - ], - ), - ); - } - ); -} - - class CheckBoxField extends FormField { CheckBoxField({ String? label, bool initial = false, Function(bool?)? onSaved, diff --git a/lib/widget/stock_item_test_results.dart b/lib/widget/stock_item_test_results.dart index bdefdc2b..bb189980 100644 --- a/lib/widget/stock_item_test_results.dart +++ b/lib/widget/stock_item_test_results.dart @@ -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'; @@ -46,73 +47,19 @@ class _StockItemTestResultDisplayState extends RefreshableState[ - 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( - context, - label: L10().attachImage, - required: attachmentRequired, - onSaved: (attachment) => _attachment = attachment, - ), - StringField( - allowEmpty: true, - label: L10().notes, - onSaved: (value) => _notes = value ?? '', - ), - ] + onSuccess: (data) { + refresh(); + }, + fileField: "attachment", ); } @@ -274,29 +221,12 @@ class _StockItemTestResultDisplayState extends RefreshableState actionButtons() { - - var buttons = List(); - - 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(); + addTestResult(context); }, ); } From 2720280ada9ec1efe8ad989170e11c857bdde917 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 16 Aug 2021 20:40:29 +1000 Subject: [PATCH 08/11] Remove floating action button --- lib/api.dart | 15 +++------------ lib/api_form.dart | 2 +- lib/widget/stock_item_test_results.dart | 22 ++++++++++++---------- pubspec.lock | 7 ------- pubspec.yaml | 1 - 5 files changed, 16 insertions(+), 31 deletions(-) diff --git a/lib/api.dart b/lib/api.dart index 7777b1a4..22b9b288 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -262,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; } @@ -319,11 +314,7 @@ class InvenTreeAPI { 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: @@ -419,7 +410,7 @@ class InvenTreeAPI { var response = await get(_URL_GET_ROLES, expectedStatusCode: 200); - if (!response.isValid() || response.statusCode != 200) { + if (!response.successful()) { return; } diff --git a/lib/api_form.dart b/lib/api_form.dart index 51366c13..88ffa885 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -144,7 +144,7 @@ class APIFormField { params: filters, ); - if (response.isValid()) { + if (response.successful()) { initial_data = response.data; } } diff --git a/lib/widget/stock_item_test_results.dart b/lib/widget/stock_item_test_results.dart index bb189980..5c8ce974 100644 --- a/lib/widget/stock_item_test_results.dart +++ b/lib/widget/stock_item_test_results.dart @@ -37,6 +37,18 @@ class _StockItemTestResultDisplayState extends RefreshableState L10().testResults; + @override + List getAppBarActions(BuildContext context) { + return [ + IconButton( + icon: FaIcon(FontAwesomeIcons.plusCircle), + onPressed: () { + addTestResult(context); + } + ), + ]; + } + @override Future request() async { await item.getTestTemplates(); @@ -220,14 +232,4 @@ class _StockItemTestResultDisplayState extends RefreshableState Date: Mon, 16 Aug 2021 20:43:12 +1000 Subject: [PATCH 09/11] Italic --- lib/widget/category_display.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/widget/category_display.dart b/lib/widget/category_display.dart index ad644978..ea4e2e30 100644 --- a/lib/widget/category_display.dart +++ b/lib/widget/category_display.dart @@ -202,7 +202,10 @@ class _CategoryDisplayState extends RefreshableState { } 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)); From 094b997f76ac73d12c12137ca984b75b66fbc5cc Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 16 Aug 2021 20:47:14 +1000 Subject: [PATCH 10/11] Refactor login page buttons --- lib/settings/login.dart | 16 ++++++++-------- lib/widget/refreshable_state.dart | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/settings/login.dart b/lib/settings/login.dart index 0e9bf5d7..3bacea7f 100644 --- a/lib/settings/login.dart +++ b/lib/settings/login.dart @@ -101,8 +101,6 @@ class _InvenTreeLoginSettingsState extends State { return; } - - _reload(); if (InvenTreeAPI().isConnected() && InvenTreeAPI().profile != null && profile.key == (InvenTreeAPI().profile?.key ?? '')) { @@ -231,6 +229,14 @@ class _InvenTreeLoginSettingsState extends State { key: _loginKey, appBar: AppBar( title: Text(L10().profileSelect), + actions: [ + IconButton( + icon: FaIcon(FontAwesomeIcons.plusCircle), + onPressed: () { + _editProfile(context, createNew: true); + }, + ) + ], ), body: Container( child: ListView( @@ -240,12 +246,6 @@ class _InvenTreeLoginSettingsState extends State { ).toList(), ) ), - floatingActionButton: FloatingActionButton( - child: Icon(FontAwesomeIcons.plus), - onPressed: () { - _editProfile(context, createNew: true); - }, - ) ); } } diff --git a/lib/widget/refreshable_state.dart b/lib/widget/refreshable_state.dart index 94134e00..aa288499 100644 --- a/lib/widget/refreshable_state.dart +++ b/lib/widget/refreshable_state.dart @@ -97,7 +97,7 @@ abstract class RefreshableState extends State { return Scaffold( key: refreshableKey, appBar: getAppBar(context), - drawer: null, // getDrawer(context), + drawer: null, floatingActionButton: getFab(context), body: Builder( builder: (BuildContext context) { From 3c51e0fd0f51d3d5a7d89c2df1b83ae7f056f8c0 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 16 Aug 2021 20:58:03 +1000 Subject: [PATCH 11/11] 0.4.6 --- assets/release_notes.md | 2 ++ pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/assets/release_notes.md b/assets/release_notes.md index db6b20b5..983069a5 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -6,6 +6,8 @@ - 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 --- diff --git a/pubspec.yaml b/pubspec.yaml index 6b006df6..2abbe3f0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,7 +7,7 @@ description: InvenTree stock management # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.4.5+26 +version: 0.4.6+27 environment: sdk: ">=2.12.0 <3.0.0"