import "dart:io"; import "package:intl/intl.dart"; import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; import "package:dropdown_search/dropdown_search.dart"; import "package:datetime_picker_formfield/datetime_picker_formfield.dart"; import "package:flutter/material.dart"; import "package:inventree/api.dart"; import "package:inventree/app_colors.dart"; import "package:inventree/barcode/barcode.dart"; import "package:inventree/helpers.dart"; import "package:inventree/l10.dart"; import "package:inventree/inventree/company.dart"; import "package:inventree/inventree/part.dart"; import "package:inventree/inventree/project_code.dart"; import "package:inventree/inventree/purchase_order.dart"; import "package:inventree/inventree/sales_order.dart"; import "package:inventree/inventree/stock.dart"; import "package:inventree/inventree/sentry.dart"; import "package:inventree/widget/dialogs.dart"; import "package:inventree/widget/fields.dart"; import "package:inventree/widget/progress.dart"; import "package:inventree/widget/snacks.dart"; /* * Class that represents a single "form field", * defined by the InvenTree API */ class APIFormField { // Constructor APIFormField(this.name, this.data); // File to be uploaded for this filed File? attachedfile; // Name of this field final String name; // JSON data which defines the field final Map data; // JSON field definition provided by the server Map definition = {}; dynamic initial_data; // Return the "lookup path" for this field, within the server data String get lookupPath { // Simple top-level case if (parent.isEmpty && !nested) { return name; } List path = []; if (parent.isNotEmpty) { path.add(parent); path.add("child"); } if (nested) { path.add("children"); path.add(name); } return path.join("."); } /* * Extract a field parameter from the provided field definition. * * - First the user-provided data is checked * - Second, the server-provided definition is checked * * - Finally, return null */ dynamic getParameter(String key) { if (data.containsKey(key)) { return data[key]; } else if (definition.containsKey(key)) { return definition[key]; } else { return null; } } // Get the "api_url" associated with a related field String get api_url => (getParameter("api_url") ?? "") as String; // Get the "model" associated with a related field String get model => (getParameter("model") ?? "") as String; // Is this field hidden? bool get hidden => (getParameter("hidden") ?? false) as bool; // Is this field nested? (Nested means part of an array) // Note: This parameter is only defined locally bool get nested => (data["nested"] ?? false) as bool; // What is the "parent" field of this field? // Note: This parameter is only defined locally String get parent => (data["parent"] ?? "") as String; bool get isSimple => !nested && parent.isEmpty; // Is this field read only? bool get readOnly => (getParameter("read_only") ?? false) as bool; bool get multiline => (getParameter("multiline") ?? false) as bool; // Get the "value" as a string (look for "default" if not available) dynamic get value => data["value"] ?? data["instance_value"] ?? defaultValue; // Render value to string (for form submission) String renderValueToString() { if (data["value"] == null) { return ""; } else { return data["value"].toString(); } } // Get the "default" as a string dynamic get defaultValue => getParameter("default"); // Construct a set of "filters" for this field (e.g. related field) Map get filters { Map _filters = {}; // Start with the field "definition" (provided by the server) if (definition.containsKey("filters")) { try { var fDef = definition["filters"] as Map; fDef.forEach((String key, dynamic value) { _filters[key] = value.toString(); }); } catch (error) { // pass } } // Next, look at any "instance_filters" provided by the server if (definition.containsKey("instance_filters")) { try { var fIns = definition["instance_filters"] as Map; fIns.forEach((String key, dynamic value) { _filters[key] = value.toString(); }); } catch (error) { // pass } } // Finally, augment or override with any filters provided by the calling function if (data.containsKey("filters")) { try { var fDat = data["filters"] as Map; fDat.forEach((String key, dynamic value) { _filters[key] = value.toString(); }); } catch (error) { // pass } } return _filters; } bool hasErrors() => errorMessages().isNotEmpty; // Extract error messages from the server response void extractErrorMessages(APIResponse response) { dynamic errors; if (isSimple) { // Simple fields are easily handled errors = response.data[name]; } else { if (parent.isNotEmpty) { dynamic parentElement = response.data[parent]; // Extract from list if (parentElement is List) { parentElement = parentElement[0]; } if (parentElement is Map) { errors = parentElement[name]; } } } data["errors"] = errors; } // Return the error message associated with this field List errorMessages() { dynamic errors = data["errors"] ?? []; // Handle the case where a single error message is returned if (errors is String) { errors = [errors]; } errors = errors as List; List messages = []; for (dynamic error in errors) { messages.add(error.toString()); } return messages; } // Is this field required? bool get required => (getParameter("required") ?? false) as bool; String get type => (getParameter("type") ?? "").toString(); String get label => (getParameter("label") ?? "").toString(); String get helpText => (getParameter("help_text") ?? "").toString(); String get placeholderText => (getParameter("placeholder") ?? "").toString(); List get choices => (getParameter("choices") ?? []) as List; Future loadInitialData() async { // Only for "related fields" if (type != "related field") { return; } // Null value? No point! if (value == null) { return; } int? pk = int.tryParse(value.toString()); if (pk == null) { return; } String url = api_url + "/" + pk.toString() + "/"; final APIResponse response = await InvenTreeAPI().get( url, params: filters, ); if (response.successful()) { initial_data = response.data; } } // Construct a widget for this input Widget constructField(BuildContext context) { switch (type) { case "string": case "url": return _constructString(); case "boolean": return _constructBoolean(); case "related field": return _constructRelatedField(); case "float": case "decimal": return _constructFloatField(); case "choice": return _constructChoiceField(); case "file upload": case "image upload": return _constructFileField(); case "date": return _constructDateField(); case "barcode": return _constructBarcodeField(context); default: return ListTile( title: Text( "Unsupported field type: '${type}' for field '${name}'", style: TextStyle( color: COLOR_DANGER, fontStyle: FontStyle.italic), ) ); } } // Field for capturing a barcode Widget _constructBarcodeField(BuildContext context) { TextEditingController controller = TextEditingController(); String barcode = (value ?? "").toString(); if (barcode.isEmpty) { barcode = L10().barcodeNotAssigned; } controller.text = barcode; return InputDecorator( decoration: InputDecoration( labelText: required ? label + "*" : label, labelStyle: _labelStyle(), helperText: helpText, helperStyle: _helperStyle(), hintText: placeholderText, ), child: ListTile( title: TextField( readOnly: true, controller: controller, ), trailing: IconButton( icon: Icon(TablerIcons.qrcode), onPressed: () async { var handler = UniqueBarcodeHandler((String hash) { controller.text = hash; data["value"] = hash; barcodeSuccess(L10().barcodeAssigned); }); scanBarcode(context, handler: handler); }, ), ) ); } // Field for displaying and selecting dates Widget _constructDateField() { DateTime? currentDate = DateTime.tryParse((value ?? "")as String); return InputDecorator( decoration: InputDecoration( labelText: label, labelStyle: _labelStyle(), helperStyle: _helperStyle(), helperText: helpText, ), child: DateTimeField( format: DateFormat("yyyy-MM-dd"), initialValue: currentDate, onChanged: (DateTime? time) { // Save the time string if (time == null) { data["value"] = null; } else { data["value"] = time.toString().split(" ").first; } }, onShowPicker: (context, value) async { final time = await showDatePicker( context: context, initialDate: currentDate ?? DateTime.now(), firstDate: DateTime(1900), lastDate: DateTime(2100), ); return time; }, ) ); } // Field for selecting and uploading files Widget _constructFileField() { TextEditingController controller = 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: Icon(TablerIcons.circle_plus), onPressed: () async { FilePickerDialog.pickFile( message: L10().attachmentSelect, onPicked: (file) { // 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; // Check if the current value is within the allowed values for (var opt in choices) { if (opt["value"] == value) { initial = opt; break; } } return DropdownSearch( popupProps: PopupProps.bottomSheet( showSelectedItems: false, searchFieldProps: TextFieldProps( autofocus: true ) ), selectedItem: initial, items: choices, dropdownDecoratorProps: DropDownDecoratorProps( dropdownSearchDecoration: InputDecoration( labelText: label, hintText: helpText, )), onChanged: null, clearButtonProps: ClearButtonProps(isVisible: !required), itemAsString: (dynamic item) { return (item["display_name"] ?? "") as String; }, onSaved: (item) { if (item == null) { data["value"] = null; } else { data["value"] = item["value"]; } }); } // Construct a floating point numerical input field Widget _constructFloatField() { // Initial value: try to cast to a valid number String initial = ""; double? initialNumber = double.tryParse(value.toString()); if (initialNumber != null) { initial = simpleNumberString(initialNumber); } return TextFormField( decoration: InputDecoration( labelText: required ? label + "*" : label, labelStyle: _labelStyle(), helperText: helpText, helperStyle: _helperStyle(), hintText: placeholderText, ), initialValue: initial, keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true), validator: (value) { value = value?.trim() ?? ""; // Allow empty numbers, *if* this field is not required if (value.isEmpty && !required) { return null; } double? quantity = double.tryParse(value.toString()); if (quantity == null) { return L10().numberInvalid; } return null; }, onSaved: (val) { data["value"] = val; }, ); } // Construct an input for a related field Widget _constructRelatedField() { return DropdownSearch( popupProps: PopupProps.bottomSheet( showSelectedItems: true, isFilterOnline: true, showSearchBox: true, itemBuilder: (context, item, isSelected) { return _renderRelatedField(name, item, isSelected, true); }, emptyBuilder: (context, item) { return _renderEmptyResult(); }, searchFieldProps: TextFieldProps( autofocus: true ) ), selectedItem: initial_data, asyncItems: (String filter) async { Map _filters = { ..._relatedFieldFilters(), ...filters, }; _filters["search"] = filter; _filters["offset"] = "0"; _filters["limit"] = "25"; final APIResponse response = await InvenTreeAPI().get(api_url, params: _filters); if (response.isValid()) { return response.resultsList(); } else { return []; } }, clearButtonProps: ClearButtonProps( isVisible: !required ), dropdownDecoratorProps: DropDownDecoratorProps( dropdownSearchDecoration: InputDecoration( labelText: label, hintText: helpText, )), onChanged: null, itemAsString: (dynamic item) { Map data = item as Map; switch (model) { case InvenTreePart.MODEL_TYPE: return InvenTreePart.fromJson(data).fullname; case InvenTreeCompany.MODEL_TYPE: return InvenTreeCompany.fromJson(data).name; case InvenTreePurchaseOrder.MODEL_TYPE: return InvenTreePurchaseOrder.fromJson(data).reference; case InvenTreeSalesOrder.MODEL_TYPE: return InvenTreeSalesOrder.fromJson(data).reference; case InvenTreePartCategory.MODEL_TYPE: return InvenTreePartCategory.fromJson(data).pathstring; case InvenTreeStockLocation.MODEL_TYPE: return InvenTreeStockLocation.fromJson(data).pathstring; default: return "itemAsString not implemented for '${model}'"; } }, dropdownBuilder: (context, item) { return _renderRelatedField(name, item, true, false); }, onSaved: (item) { if (item != null) { data["value"] = item["pk"]; } else { data["value"] = null; } }, compareFn: (dynamic item, dynamic selectedItem) { // Comparison is based on the PK value if (item == null || selectedItem == null) { return false; } bool result = false; try { result = item["pk"].toString() == selectedItem["pk"].toString(); } catch (error) { // Catch any conversion errors result = false; } return result; }); } // Construct a set of custom filters for the dropdown search Map _relatedFieldFilters() { switch (model) { case InvenTreeSupplierPart.MODEL_TYPE: return InvenTreeSupplierPart().defaultListFilters(); case InvenTreeStockItem.MODEL_TYPE: return InvenTreeStockItem().defaultListFilters(); default: break; } return {}; } // Render a "related field" based on the "model" type Widget _renderRelatedField(String fieldName, dynamic item, bool selected, bool extended) { // Convert to JSON Map data = {}; try { if (item is Map) { data = Map.from(item); } else { data = {}; } } catch (error, stackTrace) { data = {}; sentryReportError( "_renderRelatedField", error, stackTrace, context: { "method": "_renderRelateField", "field_name": fieldName, "item": item.toString(), "selected": selected.toString(), "extended": extended.toString(), } ); } switch (model) { case InvenTreePart.MODEL_TYPE: var part = InvenTreePart.fromJson(data); return ListTile( title: Text( part.fullname, style: TextStyle(fontWeight: selected && extended ? FontWeight.bold : FontWeight.normal) ), subtitle: extended ? Text( part.description, style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal), ) : null, leading: extended ? InvenTreeAPI().getThumbnail(part.thumbnail) : null, ); case InvenTreePartTestTemplate.MODEL_TYPE: var template = InvenTreePartTestTemplate.fromJson(data); return ListTile( title: Text(template.testName), subtitle: Text(template.description), ); case InvenTreeSupplierPart.MODEL_TYPE: var part = InvenTreeSupplierPart.fromJson(data); return ListTile( title: Text(part.SKU), subtitle: Text(part.partName), leading: extended ? InvenTreeAPI().getThumbnail(part.partImage) : null, trailing: extended && part.supplierImage.isNotEmpty ? InvenTreeAPI().getThumbnail(part.supplierImage) : null, ); case InvenTreePartCategory.MODEL_TYPE: var cat = InvenTreePartCategory.fromJson(data); return ListTile( title: Text( cat.pathstring, style: TextStyle(fontWeight: selected && extended ? FontWeight.bold : FontWeight.normal) ), subtitle: extended ? Text( cat.description, style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal), ) : null, ); case InvenTreeStockItem.MODEL_TYPE: var item = InvenTreeStockItem.fromJson(data); return ListTile( title: Text( item.partName, ), leading: InvenTreeAPI().getThumbnail(item.partThumbnail), trailing: Text(item.quantityString()), ); case InvenTreeStockLocation.MODEL_TYPE: var loc = InvenTreeStockLocation.fromJson(data); return ListTile( title: Text( loc.pathstring, style: TextStyle(fontWeight: selected && extended ? FontWeight.bold : FontWeight.normal) ), subtitle: extended ? Text( loc.description, style: TextStyle(fontWeight: selected ? FontWeight.bold : FontWeight.normal), ) : null, ); case InvenTreeSalesOrderShipment.MODEL_TYPE: var shipment = InvenTreeSalesOrderShipment.fromJson(data); return ListTile( title: Text(shipment.reference), subtitle: Text(shipment.tracking_number), trailing: shipment.shipped ? Text(shipment.shipment_date!) : null, ); case "owner": String name = (data["name"] ?? "") as String; bool isGroup = (data["label"] ?? "") == "group"; return ListTile( title: Text(name), leading: Icon(isGroup ? TablerIcons.users : TablerIcons.user), ); case "contact": String name = (data["name"] ?? "") as String; String role = (data["role"] ?? "") as String; return ListTile( title: Text(name), subtitle: Text(role), ); case InvenTreeCompany.MODEL_TYPE: var company = InvenTreeCompany.fromJson(data); return ListTile( title: Text(company.name), subtitle: extended ? Text(company.description) : null, leading: InvenTreeAPI().getThumbnail(company.thumbnail) ); case InvenTreeProjectCode.MODEL_TYPE: var project_code = InvenTreeProjectCode.fromJson(data); return ListTile( title: Text(project_code.code), subtitle: Text(project_code.description), leading: Icon(TablerIcons.list) ); default: return ListTile( title: Text( "Unsupported model", style: TextStyle( fontWeight: FontWeight.bold, color: COLOR_DANGER ) ), subtitle: Text("Model '${model}' rendering not supported"), ); } } // Construct a widget to instruct the user that no results were found Widget _renderEmptyResult() { return ListTile( leading: Icon(TablerIcons.search), title: Text(L10().noResults), subtitle: Text( L10().queryNoResults, style: TextStyle(fontStyle: FontStyle.italic), ), ); } // Construct a string input element Widget _constructString() { if (readOnly) { return ListTile( title: Text(label), subtitle: Text(helpText), trailing: Text(value.toString()), ); } return TextFormField( decoration: InputDecoration( labelText: required ? label + "*" : label, labelStyle: _labelStyle(), helperText: helpText, helperStyle: _helperStyle(), hintText: placeholderText, ), readOnly: readOnly, maxLines: multiline ? null : 1, expands: false, initialValue: (value ?? "") as String, onSaved: (val) { data["value"] = val; }, validator: (value) { if (required && (value == null || value.isEmpty)) { // return L10().valueCannotBeEmpty; } return null; }, ); } // Construct a boolean input element Widget _constructBoolean() { bool? initial_value; if (value is bool || value == null) { initial_value = value as bool?; } else { String vs = value.toString().toLowerCase(); initial_value = ["1", "true", "yes"].contains(vs); } return CheckBoxField( label: label, labelStyle: _labelStyle(), helperText: helpText, helperStyle: _helperStyle(), initial: initial_value, tristate: (getParameter("tristate") ?? false) as bool, onSaved: (val) { data["value"] = val; }, ); } TextStyle _labelStyle() { return TextStyle( fontWeight: FontWeight.bold, fontSize: 18, fontFamily: "arial", color: hasErrors() ? COLOR_DANGER : null, fontStyle: FontStyle.normal, ); } TextStyle _helperStyle() { return TextStyle( fontStyle: FontStyle.italic, color: hasErrors() ? COLOR_DANGER : null, ); } } /* * Extract field options from a returned OPTIONS request */ Map extractFields(APIResponse response) { if (!response.isValid()) { return {}; } var data = response.asMap(); if (!data.containsKey("actions")) { return {}; } var actions = response.data["actions"] as Map; dynamic result = actions["POST"] ?? actions["PUT"] ?? actions["PATCH"] ?? {}; return result as Map; } /* * Extract a field definition (map) from the provided JSON data. * * Notes: * - If the field is a top-level item, the provided "path" may be a simple string (e.g. "quantity"), * - If the field is buried in the JSON data, the "path" may use a dotted notation e.g. "items.child.children.quantity" * * The map "tree" is traversed based on the provided lookup string, which can use dotted notation. * This allows complex paths to be used to lookup field information. */ Map extractFieldDefinition(Map data, String lookup) { List path = lookup.split("."); // Shadow copy the data for path traversal Map _data = data; // Iterate through all but the last element of the path for (int ii = 0; ii < (path.length - 1); ii++) { String el = path[ii]; if (!_data.containsKey(el)) { print("Could not find field definition for ${lookup}:"); print("- Key ${el} missing at index ${ii}"); return {}; } try { _data = _data[el] as Map; } catch (error, stackTrace) { print("Could not find sub-field element '${el}' for ${lookup}:"); print(error.toString()); // Report the error sentryReportError( "apiForm.extractFieldDefinition : path traversal", error, stackTrace, context: { "path": path.toString(), "el": el, } ); return {}; } } String el = path.last; if (!_data.containsKey(el)) { return {}; } else { try { Map definition = _data[el] as Map; return definition; } catch (error, stacktrace) { print("Could not find field definition for ${lookup}"); print(error.toString()); // Report the error sentryReportError( "apiForm.extractFieldDefinition : as map", error, stacktrace, context: { "el": el.toString(), } ); return {}; } } } /* * Launch an API-driven form, * which uses the OPTIONS metadata (at the provided URL) * to determine how the form elements should be rendered! * * @param title is the title text to display on the form * @param url is the API URl to make the OPTIONS request to * @param fields is a map of fields to display (with optional overrides) * @param modelData is the (optional) existing modelData * @param method is the HTTP method to use to send the form data to the server (e.g. POST / PATCH) */ Future launchApiForm( BuildContext context, String title, String url, Map fields, { String fileField = "", Map modelData = const {}, String method = "PATCH", Function(Map)? onSuccess, bool Function(Map)? validate, Function? onCancel, IconData icon = TablerIcons.device_floppy }) async { showLoadingOverlay(); // List of fields defined by the server Map serverFields = {}; if (url.isNotEmpty) { var options = await InvenTreeAPI().options(url); // Invalid response from server if (!options.isValid()) { hideLoadingOverlay(); return; } serverFields = extractFields(options); if (serverFields.isEmpty) { // User does not have permission to perform this action showSnackIcon( L10().response403, icon: TablerIcons.user_x, ); hideLoadingOverlay(); return; } } // Construct a list of APIFormField objects List formFields = []; APIFormField field; for (String fieldName in fields.keys) { dynamic data = fields[fieldName]; Map fieldData = {}; if (data is Map) { fieldData = Map.from(data); } // Iterate through the provided fields we wish to display field = APIFormField(fieldName, fieldData); // Extract the definition of this field from the data received from the server field.definition = extractFieldDefinition(serverFields, field.lookupPath); // Skip fields with empty definitions if (url.isNotEmpty && field.definition.isEmpty) { print("Warning: Empty field definition for field '${fieldName}'"); } // Add instance value to the field dynamic model_value = modelData[fieldName]; if (model_value != null) { field.data["instance_value"] = model_value; if (field.data["value"] == null) { field.data["value"] = model_value; } } formFields.add(field); } // Grab existing data for each form field for (var field in formFields) { await field.loadInitialData(); } hideLoadingOverlay(); // Now, launch a new widget! Navigator.push( context, MaterialPageRoute(builder: (context) => APIFormWidget( title, url, formFields, method, onSuccess: onSuccess, validate: validate, fileField: fileField, icon: icon, )) ); } class APIFormWidget extends StatefulWidget { const APIFormWidget( this.title, this.url, this.fields, this.method, { Key? key, this.onSuccess, this.validate, this.fileField = "", this.icon = TablerIcons.device_floppy, } ) : super(key: key); //! Form title to display final String title; //! API URL final String url; //! API method final String method; final String fileField; // Icon final IconData icon; final List fields; final Function(Map)? onSuccess; final bool Function(Map)? validate; @override _APIFormWidgetState createState() => _APIFormWidgetState(); } class _APIFormWidgetState extends State { _APIFormWidgetState() : super(); final _formKey = GlobalKey(); List nonFieldErrors = []; bool spacerRequired = false; List _buildForm() { List widgets = []; // Display non-field errors first if (nonFieldErrors.isNotEmpty) { for (String error in nonFieldErrors) { widgets.add( ListTile( title: Text( error, style: TextStyle( color: COLOR_DANGER, ), ), leading: Icon( TablerIcons.exclamation_circle, color: COLOR_DANGER ), ) ); } widgets.add(Divider(height: 5)); } for (var field in widget.fields) { if (field.hidden) { continue; } // Add divider before some widgets if (spacerRequired) { switch (field.type) { case "related field": case "choice": widgets.add(Divider(height: 15)); break; default: break; } } widgets.add(field.constructField(context)); if (field.hasErrors()) { for (String error in field.errorMessages()) { widgets.add( ListTile( title: Text( error, style: TextStyle( color: COLOR_DANGER, fontStyle: FontStyle.italic, fontSize: 16, ), ) ) ); } } // Add divider after some widgets switch (field.type) { case "related field": case "choice": widgets.add(Divider(height: 15)); spacerRequired = false; break; default: spacerRequired = true; break; } } return widgets; } Future _submit(Map data) async { // If a file upload is required, we have to handle the submission differently if (widget.fileField.isNotEmpty) { // Pop the "file" field data.remove(widget.fileField); for (var field in widget.fields) { if (field.name == widget.fileField) { File? file = field.attachedfile; if (file != null) { // A valid file has been supplied final response = await InvenTreeAPI().uploadFile( widget.url, file, name: widget.fileField, fields: data, ); return response; } } } } if (widget.method == "POST") { showLoadingOverlay(); final response = await InvenTreeAPI().post( widget.url, body: data, expectedStatusCode: null ); hideLoadingOverlay(); return response; } else { showLoadingOverlay(); final response = await InvenTreeAPI().patch( widget.url, body: data, expectedStatusCode: null ); hideLoadingOverlay(); return response; } } void extractNonFieldErrors(APIResponse response) { List errors = []; Map data = response.asMap(); // Potential keys representing non-field errors List keys = [ "__all__", "non_field_errors", "errors", ]; for (String key in keys) { if (data.containsKey(key)) { dynamic result = data[key]; if (result is String) { errors.add(result); } else if (result is List) { for (dynamic element in result) { errors.add(element.toString()); } } } } nonFieldErrors = errors; } /* Check for errors relating to an *unhandled* field name * These errors will not be displayed and potentially confuse the user * So, we need to know if these are ever happening */ void checkInvalidErrors(APIResponse response) { var errors = response.asMap(); for (String fieldName in errors.keys) { bool match = false; switch (fieldName) { case "__all__": case "non_field_errors": case "errors": // ignore these global fields match = true; continue; default: for (var field in widget.fields) { // Hidden fields can't display errors, so we won't match if (field.hidden) { continue; } if (field.name == fieldName) { // Direct Match found! match = true; break; } else if (field.parent == fieldName) { var error = errors[fieldName]; if (error is List) { for (var el in error) { if (el is Map && el.containsKey(field.name)) { match = true; break; } } } else if (error is Map && error.containsKey(field.name)) { match = true; break; } } } break; } if (!match) { // Match for an unknown / unsupported field sentryReportMessage( "API form returned error for unsupported field", context: { "url": response.url, "status_code": response.statusCode.toString(), "field": fieldName, "error_message": response.data.toString(), } ); } } } /* * Submit the form data to the server, and handle the results */ Future _save(BuildContext context) async { // Package up the form data Map data = {}; // Iterate through and find "simple" top-level fields for (var field in widget.fields) { if (field.readOnly) { continue; } if (field.isSimple) { // Simple top-level field data data[field.name] = field.data["value"]; } else { // Not so simple... (WHY DID I MAKE THE API SO COMPLEX?) if (field.parent.isNotEmpty) { // TODO: This is a dirty hack, there *must* be a cleaner way?! dynamic parent = data[field.parent] ?? {}; // In the case of a "nested" object, we need to extract the first item if (parent is List) { parent = parent.first; } parent[field.name] = field.data["value"]; // Nested fields must be handled as an array! // For now, we only allow single length nested fields if (field.nested) { parent = [parent]; } data[field.parent] = parent; } } } final bool isValid = widget.validate?.call(data) ?? true; if (!isValid) { return; } // Run custom onSuccess function var successFunc = widget.onSuccess; // An "empty" URL means we don't want to submit the form anywhere // Perhaps we just want to process the data? if (widget.url.isEmpty) { // Hide the form Navigator.pop(context); if (successFunc != null) { // Return the raw "submitted" data, rather than the server response successFunc(data); } return; } final response = await _submit(data); if (!response.isValid()) { showServerError(widget.url, L10().serverError, L10().responseInvalid); return; } switch (response.statusCode) { case 200: case 201: // Form was successfully validated by the server // Hide this form Navigator.pop(context); if (successFunc != null) { // Ensure the response is a valid JSON structure Map json = {}; var data = response.asMap(); for (String key in data.keys) { json[key.toString()] = data[key]; } successFunc(json); } return; case 400: // Form submission / validation error showSnackIcon( L10().formError, success: false, ); // Update field errors for (var field in widget.fields) { field.extractErrorMessages(response); } extractNonFieldErrors(response); checkInvalidErrors(response); break; case 401: showSnackIcon( "401: " + L10().response401, success: false ); break; case 403: showSnackIcon( "403: " + L10().response403, success: false, ); break; case 404: showSnackIcon( "404: " + L10().response404, success: false, ); break; case 405: showSnackIcon( "405: " + L10().response405, success: false, ); break; case 500: showSnackIcon( "500: " + L10().response500, success: false, ); break; default: showSnackIcon( "${response.statusCode}: " + L10().responseInvalid, success: false, ); break; } setState(() { // Refresh the form }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), backgroundColor: COLOR_APP_BAR, actions: [ IconButton( icon: Icon(widget.icon), onPressed: () { if (_formKey.currentState!.validate()) { _formKey.currentState!.save(); _save(context); } }, ) ] ), body: Form( key: _formKey, child: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: _buildForm(), ), padding: EdgeInsets.all(16), ) ) ); } }