diff --git a/assets/release_notes.md b/assets/release_notes.md index fe7cd9b7..bf6e7837 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -1,6 +1,14 @@ ## InvenTree App Release Notes --- +### 0.8.0 - July 2022 +--- + +- Display part variants in the part detail view +- Display Bill of Materials in the part detail view +- Indicate available quantity in stock detail view +- Adds configurable filtering to various list views + ### 0.7.3 - June 2022 --- diff --git a/lib/inventree/bom.dart b/lib/inventree/bom.dart index 52ad5782..20487bcd 100644 --- a/lib/inventree/bom.dart +++ b/lib/inventree/bom.dart @@ -34,6 +34,9 @@ class InvenTreeBomItem extends InvenTreeModel { }; } + // Extract the 'reference' value associated with this BomItem + String get reference => (jsondata["reference"] ?? "") as String; + // Extract the 'quantity' value associated with this BomItem double get quantity => double.tryParse(jsondata["quantity"].toString()) ?? 0; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8183af6b..4c00ca11 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -294,6 +294,9 @@ "feedbackSuccess": "Feedback submitted", "@feedbackSuccess": {}, + "filteringOptions": "Filtering Options", + "@filteringOptions": {}, + "formatException": "Format Exception", "@formatException": {}, @@ -425,6 +428,9 @@ "lastUpdated": "Last Updated", "@lastUpdated": {}, + "level": "Level", + "@level": {}, + "lineItem": "Line Item", "@lineItem": {}, @@ -685,6 +691,9 @@ "receivedItem": "Received Stock Item", "@receivedItem": {}, + "reference": "Reference", + "@reference": {}, + "refresh": "Refresh", "@refresh": {}, diff --git a/lib/widget/attachment_widget.dart b/lib/widget/attachment_widget.dart index 8437ae7a..0a95d0fb 100644 --- a/lib/widget/attachment_widget.dart +++ b/lib/widget/attachment_widget.dart @@ -1,9 +1,3 @@ -/* - * A generic widget for displaying a list of attachments. - * - * To allow use with different "types" of attachments, - * we pass a subclassed instance of the InvenTreeAttachment model. - */ import "dart:io"; @@ -17,6 +11,12 @@ import "package:inventree/widget/refreshable_state.dart"; import "package:inventree/l10.dart"; import "package:url_launcher/url_launcher.dart"; +/* + * A generic widget for displaying a list of attachments. + * + * To allow use with different "types" of attachments, + * we pass a subclassed instance of the InvenTreeAttachment model. + */ class AttachmentWidget extends StatefulWidget { const AttachmentWidget(this.attachment, this.referenceId, this.hasUploadPermission) : super(); diff --git a/lib/widget/back.dart b/lib/widget/back.dart index 9797e4b7..27a9c23a 100644 --- a/lib/widget/back.dart +++ b/lib/widget/back.dart @@ -1,11 +1,10 @@ +import "package:flutter/material.dart"; + /* - * A custom implementation of a "Back" button for display in the app drawer + * Construct a custom back button with special feature! * * Long-pressing on this will return the user to the home screen */ - -import "package:flutter/material.dart"; - Widget backButton(BuildContext context, GlobalKey key) { return GestureDetector( diff --git a/lib/widget/bom_list.dart b/lib/widget/bom_list.dart index 5e51feeb..3014fb02 100644 --- a/lib/widget/bom_list.dart +++ b/lib/widget/bom_list.dart @@ -1,5 +1,4 @@ - import "package:flutter/material.dart"; import "package:inventree/api.dart"; @@ -16,25 +15,22 @@ import "package:inventree/widget/refreshable_state.dart"; /* - * Widget for displaying a list of BomItems for the specified 'parent' Part instance + * Widget for displaying a Bill of Materials for a specified Part instance */ -class BomList extends StatefulWidget { +class BillOfMaterialsWidget extends StatefulWidget { - const BomList(this.parent); + const BillOfMaterialsWidget(this.part, {Key? key}) : super(key: key); - final InvenTreePart parent; + final InvenTreePart part; @override - _BomListState createState() => _BomListState(parent); - + _BillOfMaterialsState createState() => _BillOfMaterialsState(part); } +class _BillOfMaterialsState extends RefreshableState { + _BillOfMaterialsState(this.part); -class _BomListState extends RefreshableState { - - _BomListState(this.parent); - - final InvenTreePart parent; + final InvenTreePart part; @override String getAppBarTitle(BuildContext context) => L10().billOfMaterials; @@ -42,7 +38,7 @@ class _BomListState extends RefreshableState { @override Widget getBody(BuildContext context) { return PaginatedBomList({ - "part": parent.pk.toString(), + "part": part.pk.toString(), }); } } @@ -62,6 +58,7 @@ class PaginatedBomList extends StatefulWidget { @override _PaginatedBomListState createState() => _PaginatedBomListState(filters, onTotalChanged); + } @@ -71,6 +68,15 @@ class _PaginatedBomListState extends PaginatedSearchState { Function(int)? onTotalChanged; + @override + String get prefix => "bom_"; + + @override + Map get orderingOptions => { + "quantity": L10().quantity, + "sub_part": L10().part, + }; + @override Future requestPage(int limit, int offset, Map params) async { @@ -87,11 +93,10 @@ class _PaginatedBomListState extends PaginatedSearchState { InvenTreePart? subPart = bomItem.subPart; String title = subPart?.fullname ?? "error - no name"; - String description = subPart?.description ?? "error - no description"; return ListTile( title: Text(title), - subtitle: Text(description), + subtitle: Text(bomItem.reference), trailing: Text( simpleNumberString(bomItem.quantity), style: TextStyle(fontWeight: FontWeight.bold), diff --git a/lib/widget/category_list.dart b/lib/widget/category_list.dart index 8bb24653..0a6ca607 100644 --- a/lib/widget/category_list.dart +++ b/lib/widget/category_list.dart @@ -50,6 +50,15 @@ class _PaginatedPartCategoryListState extends PaginatedSearchState filters) : super(filters); + @override + String get prefix => "category_"; + + @override + Map get orderingOptions => { + "name": L10().name, + "level": L10().level, + }; + @override Future requestPage(int limit, int offset, Map params) async { diff --git a/lib/widget/home.dart b/lib/widget/home.dart index 19f1c953..0870b02c 100644 --- a/lib/widget/home.dart +++ b/lib/widget/home.dart @@ -192,7 +192,7 @@ class _InvenTreeHomePageState extends State { } - Widget _listTile(BuildContext context, String label, IconData icon, {Function()? callback, String role = "", String permission = ""}) { + Widget _listTile(BuildContext context, String label, IconData icon, {Function()? callback, String role = "", String permission = "", Widget? trailing}) { bool connected = InvenTreeAPI().isConnected(); @@ -211,6 +211,7 @@ class _InvenTreeHomePageState extends State { child: ListTile( leading: FaIcon(icon, color: connected && allowed ? COLOR_CLICK : Colors.grey), title: Text(label), + trailing: trailing, ), ), onTap: () { @@ -257,7 +258,7 @@ class _InvenTreeHomePageState extends State { FontAwesomeIcons.shapes, callback: () { _showParts(context); - } + }, )); // Starred parts diff --git a/lib/widget/notifications.dart b/lib/widget/notifications.dart index 8c03343b..0af32b8b 100644 --- a/lib/widget/notifications.dart +++ b/lib/widget/notifications.dart @@ -24,7 +24,7 @@ class _NotificationState extends RefreshableState { List notifications = []; @override - AppBar? buildAppBar(BuildContext context) { + AppBar? buildAppBar(BuildContext context, GlobalKey key) { // No app bar for the notification widget return null; } diff --git a/lib/widget/paginator.dart b/lib/widget/paginator.dart index aa62647d..91ba215e 100644 --- a/lib/widget/paginator.dart +++ b/lib/widget/paginator.dart @@ -3,12 +3,24 @@ import "package:flutter/material.dart"; import "package:font_awesome_flutter/font_awesome_flutter.dart"; import "package:infinite_scroll_pagination/infinite_scroll_pagination.dart"; -import "package:inventree/inventree/model.dart"; -import "package:inventree/inventree/sentry.dart"; +import "package:inventree/api_form.dart"; +import "package:inventree/app_colors.dart"; import "package:inventree/l10.dart"; +import "package:inventree/inventree/model.dart"; +import "package:inventree/inventree/sentry.dart"; +import "package:inventree/preferences.dart"; -class PaginatedSearchState extends State { +import "package:inventree/widget/refreshable_state.dart"; + + +/* + * Generic stateful widget for displaying paginated data retrieved via the API + * + * - Can be displayed as "full screen" (with app-bar and drawer) + * - Can be displayed as a standalone widget + */ +class PaginatedSearchState extends State with BaseWidgetProperties { PaginatedSearchState(this.filters); @@ -16,11 +28,130 @@ class PaginatedSearchState extends State { static const _pageSize = 25; + // Prefix for storing and loading pagination options + // Override in implementing class + String get prefix => "prefix_"; + + // Return a map of sorting options available for this list + // Should be overridden by an implementing subclass + Map get orderingOptions => {}; + + // Return the selected ordering "field" for this list widget + Future orderingField() async { + dynamic field = await InvenTreeSettingsManager().getValue("${prefix}ordering_field", null); + + if (field != null && orderingOptions.containsKey(field.toString())) { + // A valid ordering field has been found + return field.toString(); + } else if (orderingOptions.isNotEmpty) { + // By default, return the first specified key + return orderingOptions.keys.first; + } else { + return ""; + } + } + + // Return the selected ordering "order" ("+" or "-") for this list widget + Future orderingOrder() async { + dynamic order = await InvenTreeSettingsManager().getValue("${prefix}ordering_order", "+"); + + return order == "+" ? "+" : "-"; + } + + // Return string for determining 'ordering' of paginated list + Future get orderingString async { + dynamic field = await orderingField(); + dynamic order = await orderingOrder(); + + // Return an empty string if no field is provided + if (field.toString().isEmpty) { + return ""; + } + + return "${order}${field}"; + } + + // Update the (configurable) filters for this paginated list + Future _saveOrderingOptions(BuildContext context) async { + // Retrieve stored setting + dynamic _field = await orderingField(); + dynamic _order = await orderingOrder(); + + // Construct the 'ordering' options + List> _opts = []; + + orderingOptions.forEach((k, v) => _opts.add({ + "value": k.toString(), + "display_name": v.toString() + })); + + if (_field == null && _opts.isNotEmpty) { + _field = _opts.first["value"]; + } + + Map fields = { + "ordering_field": { + "type": "choice", + "label": "Ordering Field", + "required": true, + "choices": _opts, + "value": _field, + }, + "ordering_order": { + "type": "choice", + "label": "Ordering Direction", + "required": true, + "value": _order, + "choices": [ + { + "value": "+", + "display_name": "Ascending", + }, + { + "value": "-", + "display_name": "Descending", + } + ] + } + }; + + // Launch an interactive form for the user to select options + launchApiForm( + context, + L10().filteringOptions, + "", + fields, + icon: FontAwesomeIcons.checkCircle, + onSuccess: (Map data) async { + + // Extract data from the processed form + String f = (data["ordering_field"] ?? _field) as String; + String o = (data["ordering_order"] ?? _order) as String; + + // Save values to settings + await InvenTreeSettingsManager().setValue("${prefix}ordering_field", f); + await InvenTreeSettingsManager().setValue("${prefix}ordering_order", o); + + // Refresh data from the server + _pagingController.refresh(); + } + ); + } + // Search query term String searchTerm = ""; int resultCount = 0; + String resultsString() { + + if (resultCount <= 0) { + return noResultsText; + } else { + return "${resultCount} ${L10().results}"; + } + } + // Text controller final TextEditingController searchController = TextEditingController(); @@ -42,18 +173,33 @@ class PaginatedSearchState extends State { super.dispose(); } + /* + * Custom function to request a single page of results from the server. + * Each implementing class must override this function, + * and return an InvenTreePageResponse object with the correct data format + */ Future requestPage(int limit, int offset, Map params) async { // Default implementation returns null - must be overridden return null; } + /* + * Request a single page of results from the server + */ Future _fetchPage(int pageKey) async { try { Map params = filters; + // Include user search term params["search"] = "${searchTerm}"; + // Use custom query ordering if available + String o = await orderingString; + if (o.isNotEmpty) { + params["ordering"] = o; + } + final page = await requestPage( _pageSize, pageKey, @@ -93,11 +239,14 @@ class PaginatedSearchState extends State { } } + // Callback function when the search term is updated void updateSearchTerm() { searchTerm = searchController.text; _pagingController.refresh(); } + // Function to construct a single paginated item + // Must be overridden in an implementing subclass Widget buildItem(BuildContext context, InvenTreeModel item) { // This method must be overridden by the child class @@ -107,21 +256,23 @@ class PaginatedSearchState extends State { ); } + // Return a string which is displayed when there are no results + // Can be overridden by an implementing subclass String get noResultsText => L10().noResults; @override Widget build (BuildContext context) { + return Column( mainAxisAlignment: MainAxisAlignment.start, children: [ - PaginatedSearchWidget(searchController, updateSearchTerm, resultCount), + buildSearchInput(context), Expanded( child: CustomScrollView( shrinkWrap: true, physics: ClampingScrollPhysics(), scrollDirection: Axis.vertical, slivers: [ - // TODO - Search input PagedSliverList.separated( pagingController: _pagingController, builderDelegate: PagedChildBuilderDelegate( @@ -141,46 +292,42 @@ class PaginatedSearchState extends State { ); } -} - - -class PaginatedSearchWidget extends StatelessWidget { - - const PaginatedSearchWidget(this.controller, this.onChanged, this.results); - - final Function onChanged; - - final int results; - - final TextEditingController controller; - - @override - Widget build(BuildContext context) { + /* + * Construct a search input text field for the user to enter a search term + */ + Widget buildSearchInput(BuildContext context) { return ListTile( - leading: GestureDetector( - child: FaIcon(controller.text.isEmpty ? FontAwesomeIcons.search : FontAwesomeIcons.backspace), + leading: orderingOptions.isEmpty ? null : GestureDetector( + child: FaIcon(FontAwesomeIcons.sort, color: COLOR_CLICK), + onTap: () async { + _saveOrderingOptions(context); + }, + ), + trailing: GestureDetector( + child: FaIcon( + searchController.text.isEmpty ? FontAwesomeIcons.search : FontAwesomeIcons.backspace, + color: searchController.text.isNotEmpty ? COLOR_DANGER : COLOR_CLICK, + ), onTap: () { - controller.clear(); - onChanged(); + searchController.clear(); + updateSearchTerm(); }, ), title: TextFormField( - controller: controller, + controller: searchController, onChanged: (value) { - onChanged(); + updateSearchTerm(); }, decoration: InputDecoration( hintText: L10().search, + helperText: resultsString(), ), - ), - trailing: Text( - "${results}", - style: TextStyle(fontWeight: FontWeight.bold), - ), + ) ); } } + class NoResultsWidget extends StatelessWidget { const NoResultsWidget(this.description); diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index c7e36b85..8dc162fd 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -354,7 +354,7 @@ class _PartDisplayState extends RefreshableState { Navigator.push( context, MaterialPageRoute( - builder: (context) => BomList(part) + builder: (context) => BillOfMaterialsWidget(part) ) ); } diff --git a/lib/widget/part_list.dart b/lib/widget/part_list.dart index 2be5e557..fcec2c00 100644 --- a/lib/widget/part_list.dart +++ b/lib/widget/part_list.dart @@ -61,6 +61,16 @@ class _PaginatedPartListState extends PaginatedSearchState { Function(int)? onTotalChanged; + @override + String get prefix => "part_"; + + @override + Map get orderingOptions => { + "name": L10().name, + "in_stock": L10().stock, + "IPN": L10().internalPartNumber, + }; + @override Future requestPage(int limit, int offset, Map params) async { diff --git a/lib/widget/purchase_order_list.dart b/lib/widget/purchase_order_list.dart index ec3b1a0e..ef3c6a7d 100644 --- a/lib/widget/purchase_order_list.dart +++ b/lib/widget/purchase_order_list.dart @@ -58,6 +58,17 @@ class _PaginatedPurchaseOrderListState extends PaginatedSearchState "po_"; + + @override + Map get orderingOptions => { + "reference": L10().reference, + "supplier__name": L10().supplier, + "status": L10().status, + "target_date": L10().targetDate, + }; + @override Future requestPage(int limit, int offset, Map params) async { diff --git a/lib/widget/refreshable_state.dart b/lib/widget/refreshable_state.dart index 35185690..dc29c7de 100644 --- a/lib/widget/refreshable_state.dart +++ b/lib/widget/refreshable_state.dart @@ -3,7 +3,53 @@ import "package:inventree/widget/drawer.dart"; import "package:flutter/material.dart"; -abstract class RefreshableState extends State { +/* + * Simple mixin class which defines simple methods for defining widget properties + */ +mixin BaseWidgetProperties { + + // Return a list of appBar actions (default = None) + List getAppBarActions(BuildContext context) { + return []; + } + + // Return a title for the appBar + String getAppBarTitle(BuildContext context) { return "--- app bar ---"; } + + // Function to construct a drawer (override if needed) + Widget getDrawer(BuildContext context) { + return InvenTreeDrawer(context); + } + + // Function to construct a body (MUST BE PROVIDED) + Widget getBody(BuildContext context) { + + // Default return is an empty ListView + return ListView(); + } + + Widget? getBottomNavBar(BuildContext context) { + return null; + } + + AppBar? buildAppBar(BuildContext context, GlobalKey key) { + return AppBar( + title: Text(getAppBarTitle(context)), + actions: getAppBarActions(context), + leading: backButton(context, key), + ); + } + +} + + +/* + * Abstract base class which provides generic "refresh" functionality. + * + * - Drag down and release to 'refresh' the widget + * - Define some method which runs to 'refresh' the widget state + */ +abstract class RefreshableState extends State with BaseWidgetProperties { final refreshableKey = GlobalKey(); @@ -25,12 +71,6 @@ abstract class RefreshableState extends State { }); } - List getAppBarActions(BuildContext context) { - return []; - } - - String getAppBarTitle(BuildContext context) { return "App Bar Title"; } - @override void initState() { super.initState(); @@ -60,34 +100,6 @@ abstract class RefreshableState extends State { }); } - // Function to construct a drawer (override if needed) - Widget getDrawer(BuildContext context) { - return InvenTreeDrawer(context); - } - - // Function to construct a body (MUST BE PROVIDED) - Widget getBody(BuildContext context) { - - // Default return is an empty ListView - return ListView(); - } - - Widget? getBottomNavBar(BuildContext context) { - return null; - } - - Widget? getFab(BuildContext context) { - return null; - } - - AppBar? buildAppBar(BuildContext context) { - return AppBar( - title: Text(getAppBarTitle(context)), - actions: getAppBarActions(context), - leading: backButton(context, refreshableKey), - ); - } - @override Widget build(BuildContext context) { @@ -96,9 +108,8 @@ abstract class RefreshableState extends State { return Scaffold( key: refreshableKey, - appBar: buildAppBar(context), + appBar: buildAppBar(context, refreshableKey), drawer: getDrawer(context), - floatingActionButton: getFab(context), body: Builder( builder: (BuildContext context) { return RefreshIndicator( diff --git a/lib/widget/search.dart b/lib/widget/search.dart index 49662476..dcbe47e8 100644 --- a/lib/widget/search.dart +++ b/lib/widget/search.dart @@ -53,9 +53,9 @@ class _SearchDisplayState extends RefreshableState { String getAppBarTitle(BuildContext context) => L10().search; @override - AppBar? buildAppBar(BuildContext context) { + AppBar? buildAppBar(BuildContext context, GlobalKey key) { if (hasAppBar) { - return super.buildAppBar(context); + return super.buildAppBar(context, key); } else { return null; } diff --git a/lib/widget/stock_list.dart b/lib/widget/stock_list.dart index 90481c78..19edcd54 100644 --- a/lib/widget/stock_list.dart +++ b/lib/widget/stock_list.dart @@ -52,6 +52,20 @@ class _PaginatedStockItemListState extends PaginatedSearchState filters) : super(filters); + @override + String get prefix => "stock_"; + + @override + Map get orderingOptions => { + "part__name": L10().name, + "part__IPN": L10().internalPartNumber, + "quantity": L10().quantity, + "status": L10().status, + "batch": L10().batchCode, + "updated": L10().lastUpdated, + "stocktake_date": L10().lastStocktake, + }; + @override Future requestPage(int limit, int offset, Map params) async { diff --git a/pubspec.yaml b/pubspec.yaml index 16a35064..6528ab40 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,11 +10,11 @@ dependencies: audioplayers: ^0.20.1 # Play audio files cached_network_image: ^3.2.0 # Download and cache remote images - camera: ^0.9.4 # Camera + camera: ^0.9.4 # Camera cupertino_icons: ^1.0.3 datetime_picker_formfield: ^2.0.0 # Date / time picker device_info_plus: ^3.2.2 # Information about the device - dropdown_search: ^0.6.3 # Dropdown autocomplete form fields + dropdown_search: ^0.6.3 # Dropdown autocomplete form fields file_picker: ^4.5.1 # Select files from the device flutter: sdk: flutter @@ -28,14 +28,14 @@ dependencies: infinite_scroll_pagination: ^3.1.0 # Let the server do all the work! intl: ^0.17.0 one_context: ^1.1.0 # Dialogs without requiring context - open_file: ^3.2.1 # Open local files + open_file: ^3.2.1 # Open local files package_info_plus: ^1.0.4 # App information introspection path: ^1.8.0 - path_provider: ^2.0.2 # Local file storage + path_provider: ^2.0.2 # Local file storage qr_code_scanner: ^0.7.0 # Barcode scanning sembast: ^3.1.0+2 # NoSQL data storage - sentry_flutter: ^6.4.0 # Error reporting - url_launcher: ^6.0.9 # Open link in system browser + sentry_flutter: ^6.4.0 # Error reporting + url_launcher: ^6.0.9 # Open link in system browser dev_dependencies: flutter_launcher_icons: ^0.9.0