diff --git a/lib/api_form.dart b/lib/api_form.dart index c6da25e4..8aef06a2 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -704,7 +704,8 @@ class APIFormField { labelStyle: _labelStyle(), helperText: helpText, helperStyle: _helperStyle(), - initial: value as bool, + initial: value as bool?, + tristate: (getParameter("tristate") ?? false) as bool, onSaved: (val) { data["value"] = val; }, diff --git a/lib/inventree/part.dart b/lib/inventree/part.dart index aa71ca34..9cbc9c84 100644 --- a/lib/inventree/part.dart +++ b/lib/inventree/part.dart @@ -32,15 +32,6 @@ class InvenTreePartCategory extends InvenTreeModel { }; } - @override - Map defaultListFilters() { - - return { - "active": "true", - "cascade": "false" - }; - } - String get pathstring => (jsondata["pathstring"] ?? "") as String; String get parentPathString { @@ -171,8 +162,7 @@ class InvenTreePart extends InvenTreeModel { @override Map defaultListFilters() { return { - "cascade": "false", - "active": "true", + "location_detail": "true", }; } diff --git a/lib/inventree/stock.dart b/lib/inventree/stock.dart index 12f0f2dc..406b625f 100644 --- a/lib/inventree/stock.dart +++ b/lib/inventree/stock.dart @@ -224,7 +224,6 @@ class InvenTreeStockItem extends InvenTreeModel { "part_detail": "true", "location_detail": "true", "supplier_detail": "true", - "cascade": "false", "in_stock": "true", }; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ecbb6f94..4fbf5fd7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -297,6 +297,48 @@ "feedbackSuccess": "Feedback submitted", "@feedbackSuccess": {}, + "filterActive": "Active", + "@filterActive": {}, + + "filterActiveDetail": "Show active parts", + "@filterActiveDetail": {}, + + "filterAssembly": "Assembled", + "@filterAssembly": {}, + + "filterAssemblyDetail": "Show assembled parts", + "@filterAssemblyDetail": {}, + + "filterComponent": "Component", + "@filterComponent": {}, + + "filterComponentDetail": "Show component parts", + "@filterComponentDetail": {}, + + "filterInStock": "In Stock", + "@filterInStock": {}, + + "filterInStockDetail": "Show parts which have stock", + "@filterInStockDetail": {}, + + "filterSerialized": "Serialized", + "@filterSerialized": {}, + + "filterSerializedDetail": "Show serialized stock items", + "@filterSerializedDetail": {}, + + "filterTemplate": "Template", + "@filterTemplate": {}, + + "filterTemplateDetail": "Show template parts", + "@filterTemplateDetail": {}, + + "filterVirtual": "Virtual", + "@filterVirtual": {}, + + "filterVirtualDetail": "Show virtual parts", + "@filterVirtualDetail": {}, + "filteringOptions": "Filtering Options", "@filteringOptions": {}, @@ -368,13 +410,13 @@ "includeSubcategories": "Include Subcategories", "@includeSubcategories": {}, - "includeSubcategoriesDetail": "Display subcategory parts in list view", + "includeSubcategoriesDetail": "Show results from subcategories", "@includeSubcategoriesDetail": {}, "includeSublocations": "Include Sublocations", "@includeSublocations": {}, - "includeSublocationsDetail": "Display sublocation items in list view", + "includeSublocationsDetail": "Show results from sublocations", "@includeSublocationsDetail": {}, "incompleteDetails": "Incomplete profile details", diff --git a/lib/preferences.dart b/lib/preferences.dart index b804cbd9..f1dcef45 100644 --- a/lib/preferences.dart +++ b/lib/preferences.dart @@ -16,9 +16,6 @@ const String INV_HOME_SHOW_SUPPLIERS = "homeShowSuppliers"; const String INV_SOUNDS_BARCODE = "barcodeSounds"; const String INV_SOUNDS_SERVER = "serverSounds"; -const String INV_PART_SUBCATEGORY = "partSubcategory"; - -const String INV_STOCK_SUBLOCATION = "stockSublocation"; const String INV_STOCK_SHOW_HISTORY = "stockShowHistory"; const String INV_REPORT_ERRORS = "reportErrors"; @@ -86,6 +83,11 @@ class InvenTreeSettingsManager { Future get _db async => InvenTreePreferencesDB.instance.database; + + Future removeValue(String key) async { + await store.record(key).delete(await _db); + } + Future getValue(String key, dynamic backup) async { final value = await store.record(key).get(await _db); @@ -103,13 +105,40 @@ class InvenTreeSettingsManager { if (value is bool) { return value; + } else if (value is String) { + return value.toLowerCase().contains("t"); } else { - return backup; + return false; } } + // Load a tristate (true / false / null) setting + Future getTriState(String key, dynamic backup) async { + final dynamic value = await getValue(key, backup); + + if (value == null) { + return null; + } else if (value is bool) { + return value; + } else { + String s = value.toString().toLowerCase(); + + if (s.contains("t")) { + return true; + } else if (s.contains("f")) { + return false; + } else { + return null; + } + } + } + + // Store a key:value pair in the database Future setValue(String key, dynamic value) async { + // Encode null values as strings + value ??= "null"; + await store.record(key).put(await _db, value); } diff --git a/lib/settings/app_settings.dart b/lib/settings/app_settings.dart index f322bf7c..d788d646 100644 --- a/lib/settings/app_settings.dart +++ b/lib/settings/app_settings.dart @@ -21,11 +21,7 @@ class _InvenTreeAppSettingsState extends State { bool barcodeSounds = true; bool serverSounds = true; - // Part settings - bool partSubcategory = false; - // Stock settings - bool stockSublocation = false; bool stockShowHistory = false; bool reportErrors = true; @@ -45,9 +41,6 @@ class _InvenTreeAppSettingsState extends State { barcodeSounds = await InvenTreeSettingsManager().getValue(INV_SOUNDS_BARCODE, true) as bool; serverSounds = await InvenTreeSettingsManager().getValue(INV_SOUNDS_SERVER, true) as bool; - partSubcategory = await InvenTreeSettingsManager().getValue(INV_PART_SUBCATEGORY, true) as bool; - - stockSublocation = await InvenTreeSettingsManager().getValue(INV_STOCK_SUBLOCATION, true) as bool; stockShowHistory = await InvenTreeSettingsManager().getValue(INV_STOCK_SHOW_HISTORY, false) as bool; reportErrors = await InvenTreeSettingsManager().getValue(INV_REPORT_ERRORS, true) as bool; @@ -68,49 +61,13 @@ class _InvenTreeAppSettingsState extends State { body: Container( child: ListView( children: [ - ListTile( - title: Text( - L10().parts, - style: TextStyle(fontWeight: FontWeight.bold), - ), - leading: FaIcon(FontAwesomeIcons.shapes), - ), - ListTile( - title: Text(L10().includeSubcategories), - subtitle: Text(L10().includeSubcategoriesDetail), - leading: FaIcon(FontAwesomeIcons.sitemap), - trailing: Switch( - value: partSubcategory, - onChanged: (bool value) { - InvenTreeSettingsManager().setValue(INV_PART_SUBCATEGORY, value); - setState(() { - partSubcategory = value; - }); - }, - ), - ), /* Stock Settings */ - Divider(height: 3), ListTile( title: Text(L10().stock, style: TextStyle(fontWeight: FontWeight.bold), ), leading: FaIcon(FontAwesomeIcons.boxes), ), - ListTile( - title: Text(L10().includeSublocations), - subtitle: Text(L10().includeSublocationsDetail), - leading: FaIcon(FontAwesomeIcons.sitemap), - trailing: Switch( - value: stockSublocation, - onChanged: (bool value) { - InvenTreeSettingsManager().setValue(INV_STOCK_SUBLOCATION, value); - setState(() { - stockSublocation = value; - }); - }, - ), - ), ListTile( title: Text(L10().stockItemHistory), subtitle: Text(L10().stockItemHistoryDetail), diff --git a/lib/widget/bom_list.dart b/lib/widget/bom_list.dart index 3014fb02..402bffcd 100644 --- a/lib/widget/bom_list.dart +++ b/lib/widget/bom_list.dart @@ -1,5 +1,6 @@ import "package:flutter/material.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; import "package:inventree/api.dart"; import "package:inventree/helpers.dart"; @@ -32,14 +33,31 @@ class _BillOfMaterialsState extends RefreshableState { final InvenTreePart part; + bool showFilterOptions = false; + @override String getAppBarTitle(BuildContext context) => L10().billOfMaterials; + @override + List getAppBarActions(BuildContext context) => [ + IconButton( + icon: FaIcon(FontAwesomeIcons.filter), + onPressed: () async { + setState(() { + showFilterOptions = !showFilterOptions; + }); + }, + ) + ]; + @override Widget getBody(BuildContext context) { - return PaginatedBomList({ - "part": part.pk.toString(), - }); + return PaginatedBomList( + { + "part": part.pk.toString(), + }, + showFilterOptions, + ); } } @@ -47,26 +65,18 @@ class _BillOfMaterialsState extends RefreshableState { /* * Create a paginated widget displaying a list of BomItem objects */ -class PaginatedBomList extends StatefulWidget { +class PaginatedBomList extends PaginatedSearchWidget { - const PaginatedBomList(this.filters, {this.onTotalChanged}); - - final Map filters; - - final Function(int)? onTotalChanged; + const PaginatedBomList(Map filters, bool showSearch) : super(filters: filters, showSearch: showSearch); @override - _PaginatedBomListState createState() => _PaginatedBomListState(filters, onTotalChanged); - - + _PaginatedBomListState createState() => _PaginatedBomListState(); } class _PaginatedBomListState extends PaginatedSearchState { - _PaginatedBomListState(Map filters, this.onTotalChanged) : super(filters); - - Function(int)? onTotalChanged; + _PaginatedBomListState() : super(); @override String get prefix => "bom_"; @@ -77,6 +87,14 @@ class _PaginatedBomListState extends PaginatedSearchState { "sub_part": L10().part, }; + @override + Map> get filterOptions => { + "sub_part_assembly": { + "label": L10().filterAssembly, + "help_text": L10().filterAssemblyDetail, + } + }; + @override Future requestPage(int limit, int offset, Map params) async { diff --git a/lib/widget/category_display.dart b/lib/widget/category_display.dart index 78b2128f..74575169 100644 --- a/lib/widget/category_display.dart +++ b/lib/widget/category_display.dart @@ -1,14 +1,15 @@ import "package:flutter/material.dart"; - import "package:font_awesome_flutter/font_awesome_flutter.dart"; import "package:inventree/api.dart"; import "package:inventree/app_colors.dart"; -import "package:inventree/inventree/part.dart"; -import "package:inventree/widget/part_list.dart"; -import "package:inventree/widget/progress.dart"; -import "package:inventree/widget/snacks.dart"; import "package:inventree/l10.dart"; + +import "package:inventree/inventree/part.dart"; + +import "package:inventree/widget/category_list.dart"; +import "package:inventree/widget/part_list.dart"; +import "package:inventree/widget/snacks.dart"; import "package:inventree/widget/part_detail.dart"; import "package:inventree/widget/refreshable_state.dart"; @@ -28,6 +29,8 @@ class _CategoryDisplayState extends RefreshableState { _CategoryDisplayState(this.category); + bool showFilterOptions = false; + @override String getAppBarTitle(BuildContext context) => L10().partCategory; @@ -73,8 +76,6 @@ class _CategoryDisplayState extends RefreshableState { // The local InvenTreePartCategory object final InvenTreePartCategory? category; - List _subcategories = []; - @override Future onBuild(BuildContext context) async { refresh(context); @@ -83,8 +84,6 @@ class _CategoryDisplayState extends RefreshableState { @override Future request(BuildContext context) async { - int pk = category?.pk ?? -1; - // Update the category if (category != null) { final bool result = await category?.reload() ?? false; @@ -93,27 +92,17 @@ class _CategoryDisplayState extends RefreshableState { Navigator.of(context).pop(); } } - - // Request a list of sub-categories under this one - await InvenTreePartCategory().list(filters: {"parent": "$pk"}).then((var cats) { - _subcategories.clear(); - - for (var cat in cats) { - if (cat is InvenTreePartCategory) { - _subcategories.add(cat); - } - } - - // Update state - setState(() {}); - }); } Widget getCategoryDescriptionCard({bool extra = true}) { if (category == null) { return Card( child: ListTile( - title: Text(L10().partCategoryTopLevel) + leading: FaIcon(FontAwesomeIcons.shapes), + title: Text( + L10().partCategoryTopLevel, + style: TextStyle(fontStyle: FontStyle.italic), + ) ) ); } else { @@ -182,7 +171,9 @@ class _CategoryDisplayState extends RefreshableState { ); } + // Construct the "details" panel List detailTiles() { + List tiles = [ getCategoryDescriptionCard(), ListTile( @@ -190,27 +181,62 @@ class _CategoryDisplayState extends RefreshableState { L10().subcategories, style: TextStyle(fontWeight: FontWeight.bold) ), - trailing: _subcategories.isNotEmpty ? Text("${_subcategories.length}") : null, + trailing: GestureDetector( + child: FaIcon(FontAwesomeIcons.filter), + onTap: () async { + setState(() { + showFilterOptions = !showFilterOptions; + }); + }, + ) ), + Expanded( + child: PaginatedPartCategoryList( + { + "parent": category?.pk.toString() ?? "null" + }, + showFilterOptions, + ), + flex: 10, + ) ]; - if (loading) { - tiles.add(progressIndicator()); - } else if (_subcategories.isEmpty) { - tiles.add(ListTile( - title: Text(L10().noSubcategories), - subtitle: Text( - L10().noSubcategoriesAvailable, - style: TextStyle(fontStyle: FontStyle.italic) - ) - )); - } else { - tiles.add(SubcategoryList(_subcategories)); - } - return tiles; } + // Construct the "parts" panel + List partsTiles() { + + Map filters = { + "category": category?.pk.toString() ?? "null", + }; + + return [ + getCategoryDescriptionCard(extra: false), + ListTile( + title: Text( + L10().parts, + style: TextStyle(fontWeight: FontWeight.bold), + ), + trailing: GestureDetector( + child: FaIcon(FontAwesomeIcons.filter), + onTap: () async { + setState(() { + showFilterOptions = !showFilterOptions; + }); + }, + ), + ), + Expanded( + child: PaginatedPartList( + filters, + showFilterOptions, + ), + flex: 10, + ) + ]; + } + Future _newCategory(BuildContext context) async { int pk = category?.pk ?? -1; @@ -323,14 +349,12 @@ class _CategoryDisplayState extends RefreshableState { switch (tabIndex) { case 0: - return ListView( - children: detailTiles() + return Column( + children: detailTiles() ); case 1: - return PaginatedPartList( - { - "category": "${category?.pk ?? 'null'}" - }, + return Column( + children: partsTiles() ); case 2: return ListView( @@ -341,47 +365,3 @@ class _CategoryDisplayState extends RefreshableState { } } } - - -/* - * Builder for displaying a list of PartCategory objects - */ -class SubcategoryList extends StatelessWidget { - - const SubcategoryList(this._categories); - - final List _categories; - - void _openCategory(BuildContext context, int pk) { - - // Attempt to load the sub-category. - InvenTreePartCategory().get(pk).then((var cat) { - if (cat is InvenTreePartCategory) { - - Navigator.push(context, MaterialPageRoute(builder: (context) => CategoryDisplayWidget(cat))); - } - }); - } - - Widget _build(BuildContext context, int index) { - InvenTreePartCategory cat = _categories[index]; - - return ListTile( - title: Text("${cat.name}"), - subtitle: Text("${cat.description}"), - trailing: Text("${cat.partcount}"), - onTap: () { - _openCategory(context, cat.pk); - } - ); - } - - @override - Widget build(BuildContext context) { - return ListView.separated( - shrinkWrap: true, - physics: ClampingScrollPhysics(), - separatorBuilder: (_, __) => const Divider(height: 3), - itemBuilder: _build, itemCount: _categories.length); - } -} diff --git a/lib/widget/category_list.dart b/lib/widget/category_list.dart index 0a6ca607..7ad45619 100644 --- a/lib/widget/category_list.dart +++ b/lib/widget/category_list.dart @@ -1,4 +1,5 @@ import "package:flutter/material.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; import "package:inventree/inventree/model.dart"; import "package:inventree/inventree/part.dart"; @@ -25,34 +26,55 @@ class _PartCategoryListState extends RefreshableState { final Map filters; + bool showFilterOptions = false; + + @override + List getAppBarActions(BuildContext context) => [ + IconButton( + icon: FaIcon(FontAwesomeIcons.filter), + onPressed: () async { + setState(() { + showFilterOptions = !showFilterOptions; + }); + }, + ) + ]; + @override String getAppBarTitle(BuildContext context) => L10().partCategories; @override Widget getBody(BuildContext context) { - return PaginatedPartCategoryList(filters); + return PaginatedPartCategoryList(filters, showFilterOptions); } } +class PaginatedPartCategoryList extends PaginatedSearchWidget { -class PaginatedPartCategoryList extends StatefulWidget { - - const PaginatedPartCategoryList(this.filters); - - final Map filters; + const PaginatedPartCategoryList(Map filters, bool showSearch) : super(filters: filters, showSearch: showSearch); @override - _PaginatedPartCategoryListState createState() => _PaginatedPartCategoryListState(filters); + _PaginatedPartCategoryListState createState() => _PaginatedPartCategoryListState(); } class _PaginatedPartCategoryListState extends PaginatedSearchState { - _PaginatedPartCategoryListState(Map filters) : super(filters); + // _PaginatedPartCategoryListState(Map filters, bool searchEnabled) : super(filters, searchEnabled); @override String get prefix => "category_"; + @override + Map> get filterOptions => { + "cascade": { + "default": false, + "label": L10().includeSubcategories, + "help_text": L10().includeSubcategoriesDetail, + "tristate": false, + } + }; + @override Map get orderingOptions => { "name": L10().name, diff --git a/lib/widget/company_list.dart b/lib/widget/company_list.dart index 1496826f..ed8dd4df 100644 --- a/lib/widget/company_list.dart +++ b/lib/widget/company_list.dart @@ -35,29 +35,22 @@ class _CompanyListWidgetState extends RefreshableState { @override Widget getBody(BuildContext context) { - - return PaginatedCompanyList(filters); - + return PaginatedCompanyList(filters, true); } } +class PaginatedCompanyList extends PaginatedSearchWidget { -class PaginatedCompanyList extends StatefulWidget { - - const PaginatedCompanyList(this.filters, {this.onTotalChanged}); - - final Map filters; - - final Function(int)? onTotalChanged; + const PaginatedCompanyList(Map filters, bool showSearch) : super(filters: filters, showSearch: showSearch); @override - _CompanyListState createState() => _CompanyListState(filters); + _CompanyListState createState() => _CompanyListState(); } class _CompanyListState extends PaginatedSearchState { - _CompanyListState(Map filters) : super(filters); + _CompanyListState() : super(); @override Future requestPage(int limit, int offset, Map params) async { diff --git a/lib/widget/fields.dart b/lib/widget/fields.dart index c50a20ec..a5dac730 100644 --- a/lib/widget/fields.dart +++ b/lib/widget/fields.dart @@ -142,7 +142,10 @@ class FilePickerDialog { class CheckBoxField extends FormField { CheckBoxField({ - String? label, bool initial = false, Function(bool?)? onSaved, + String? label, + bool? initial = false, + bool tristate = false, + Function(bool?)? onSaved, TextStyle? labelStyle, String? helperText, TextStyle? helperStyle, @@ -155,6 +158,7 @@ class CheckBoxField extends FormField { //dense: state.hasError, title: label != null ? Text(label, style: labelStyle) : null, value: state.value, + tristate: tristate, onChanged: state.didChange, subtitle: helperText != null ? Text(helperText, style: helperStyle) : null, contentPadding: EdgeInsets.zero, diff --git a/lib/widget/location_display.dart b/lib/widget/location_display.dart index b8169946..887f17bc 100644 --- a/lib/widget/location_display.dart +++ b/lib/widget/location_display.dart @@ -5,15 +5,20 @@ import "package:font_awesome_flutter/font_awesome_flutter.dart"; import "package:inventree/api.dart"; import "package:inventree/app_colors.dart"; import "package:inventree/barcode.dart"; +import "package:inventree/l10.dart"; + import "package:inventree/inventree/stock.dart"; -import "package:inventree/widget/progress.dart"; + +import "package:inventree/widget/location_list.dart"; import "package:inventree/widget/refreshable_state.dart"; import "package:inventree/widget/snacks.dart"; import "package:inventree/widget/stock_detail.dart"; -import "package:inventree/l10.dart"; import "package:inventree/widget/stock_list.dart"; +/* + * Widget for displaying detail view for a single StockLocation instance + */ class LocationDisplayWidget extends StatefulWidget { LocationDisplayWidget(this.location, {Key? key}) : super(key: key); @@ -32,6 +37,8 @@ class _LocationDisplayState extends RefreshableState { final InvenTreeStockLocation? location; + bool showFilterOptions = false; + @override String getAppBarTitle(BuildContext context) { return L10().stockLocation; } @@ -103,19 +110,6 @@ class _LocationDisplayState extends RefreshableState { ); } - List _sublocations = []; - - String _locationFilter = ""; - - List get sublocations { - - if (_locationFilter.isEmpty || _sublocations.isEmpty) { - return _sublocations; - } else { - return _sublocations.where((loc) => loc.filter(_locationFilter)).toList(); - } - } - @override Future onBuild(BuildContext context) async { refresh(context); @@ -124,8 +118,6 @@ class _LocationDisplayState extends RefreshableState { @override Future request(BuildContext context) async { - int pk = location?.pk ?? -1; - // Reload location information if (location != null) { final bool result = await location!.reload(); @@ -135,17 +127,6 @@ class _LocationDisplayState extends RefreshableState { } } - // Request a list of sub-locations under this one - await InvenTreeStockLocation().list(filters: {"parent": "$pk"}).then((var locs) { - _sublocations.clear(); - - for (var loc in locs) { - if (loc is InvenTreeStockLocation) { - _sublocations.add(loc); - } - } - }); - setState(() {}); } @@ -214,7 +195,11 @@ class _LocationDisplayState extends RefreshableState { if (location == null) { return Card( child: ListTile( - title: Text(L10().stockTopLevel), + title: Text( + L10().stockTopLevel, + style: TextStyle(fontStyle: FontStyle.italic) + ), + leading: FaIcon(FontAwesomeIcons.boxes), ) ); } else { @@ -223,7 +208,7 @@ class _LocationDisplayState extends RefreshableState { ListTile( title: Text("${location!.name}"), subtitle: Text("${location!.description}"), - trailing: Text("${location!.itemcount}"), + leading: FaIcon(FontAwesomeIcons.boxes), ), ]; @@ -286,20 +271,15 @@ class _LocationDisplayState extends RefreshableState { Widget getSelectedWidget(int index) { - // Construct filters for paginated stock list - Map filters = {}; - - if (location != null) { - filters["location"] = "${location!.pk}"; - } - switch (index) { case 0: - return ListView( + return Column( children: detailTiles(), ); case 1: - return PaginatedStockItemList(filters); + return Column( + children: stockTiles(), + ); case 2: return ListView( children: ListTile.divideTiles( @@ -317,8 +297,8 @@ class _LocationDisplayState extends RefreshableState { return getSelectedWidget(tabIndex); } - -List detailTiles() { + // Construct the "details" panel + List detailTiles() { List tiles = [ locationDescriptionCard(), ListTile( @@ -326,27 +306,61 @@ List detailTiles() { L10().sublocations, style: TextStyle(fontWeight: FontWeight.bold), ), - trailing: sublocations.isNotEmpty ? Text("${sublocations.length}") : null, - ), - ]; - - if (loading) { - tiles.add(progressIndicator()); - } else if (_sublocations.isNotEmpty) { - tiles.add(SublocationList(_sublocations)); - } else { - tiles.add(ListTile( - title: Text(L10().sublocationNone), - subtitle: Text( - L10().sublocationNoneDetail, - style: TextStyle(fontStyle: FontStyle.italic) + trailing: GestureDetector( + child: FaIcon(FontAwesomeIcons.filter), + onTap: () async { + setState(() { + showFilterOptions = !showFilterOptions; + }); + }, ) - )); - } + ), + Expanded( + child: PaginatedStockLocationList( + { + "parent": location?.pk.toString() ?? "null", + }, + showFilterOptions, + ), + flex: 10, + ) + ]; return tiles; } + // Construct the "stock" panel + List stockTiles() { + + Map filters = { + "location": location?.pk.toString() ?? "null", + }; + + return [ + locationDescriptionCard(includeActions: false), + ListTile( + title: Text( + L10().stock, + style: TextStyle(fontWeight: FontWeight.bold), + ), + trailing: GestureDetector( + child: FaIcon(FontAwesomeIcons.filter), + onTap: () async { + setState(() { + showFilterOptions = !showFilterOptions; + }); + }, + ), + ), + Expanded( + child: PaginatedStockItemList( + filters, + showFilterOptions, + ), + flex: 10, + ) + ]; + } List actionTiles() { List tiles = []; @@ -452,48 +466,4 @@ List detailTiles() { return tiles; } - -} - - - -class SublocationList extends StatelessWidget { - - const SublocationList(this._locations); - - final List _locations; - - void _openLocation(BuildContext context, int pk) { - - InvenTreeStockLocation().get(pk).then((var loc) { - if (loc is InvenTreeStockLocation) { - - Navigator.push(context, MaterialPageRoute(builder: (context) => LocationDisplayWidget(loc))); - } - }); - } - - Widget _build(BuildContext context, int index) { - InvenTreeStockLocation loc = _locations[index]; - - return ListTile( - title: Text("${loc.name}"), - subtitle: Text("${loc.description}"), - trailing: Text("${loc.itemcount}"), - onTap: () { - _openLocation(context, loc.pk); - }, - ); - } - - @override - Widget build(BuildContext context) { - return ListView.separated( - shrinkWrap: true, - physics: ClampingScrollPhysics(), - itemBuilder: _build, - separatorBuilder: (_, __) => const Divider(height: 3), - itemCount: _locations.length - ); - } } diff --git a/lib/widget/location_list.dart b/lib/widget/location_list.dart index 42c87920..64c0ba31 100644 --- a/lib/widget/location_list.dart +++ b/lib/widget/location_list.dart @@ -1,4 +1,5 @@ import "package:flutter/material.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; import "package:inventree/inventree/model.dart"; import "package:inventree/inventree/stock.dart"; @@ -26,30 +27,57 @@ class _StockLocationListState extends RefreshableState { final Map filters; + bool showFilterOptions = false; + + @override + List getAppBarActions(BuildContext context) => [ + IconButton( + icon: FaIcon(FontAwesomeIcons.filter), + onPressed: () async { + setState(() { + showFilterOptions = !showFilterOptions; + }); + }, + ) + ]; + @override String getAppBarTitle(BuildContext context) => L10().stockLocations; @override Widget getBody(BuildContext context) { - return PaginatedStockLocationList(filters); + return PaginatedStockLocationList(filters, showFilterOptions); } } -class PaginatedStockLocationList extends StatefulWidget { +class PaginatedStockLocationList extends PaginatedSearchWidget { - const PaginatedStockLocationList(this.filters); - - final Map filters; + const PaginatedStockLocationList(Map filters, bool showSearch) : super(filters: filters, showSearch: showSearch); @override - _PaginatedStockLocationListState createState() => _PaginatedStockLocationListState(filters); + _PaginatedStockLocationListState createState() => _PaginatedStockLocationListState(); } class _PaginatedStockLocationListState extends PaginatedSearchState { - _PaginatedStockLocationListState(Map filters) : super(filters); + _PaginatedStockLocationListState() : super(); + + @override + Map get orderingOptions => { + "name": L10().name, + "level": L10().level, + }; + + @override + Map> get filterOptions => { + "cascade": { + "label": L10().includeSublocations, + "help_text": L10().includeSublocationsDetail, + "tristate": false, + } + }; @override Future requestPage(int limit, int offset, Map params) async { diff --git a/lib/widget/paginator.dart b/lib/widget/paginator.dart index 91ba215e..12e1233f 100644 --- a/lib/widget/paginator.dart +++ b/lib/widget/paginator.dart @@ -15,23 +15,72 @@ 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 + * Abstract base widget class for rendering a PaginatedSearchState */ -class PaginatedSearchState extends State with BaseWidgetProperties { +abstract class PaginatedSearchWidget extends StatefulWidget { - PaginatedSearchState(this.filters); + const PaginatedSearchWidget({this.filters = const {}, this.showSearch = false}); final Map filters; + final bool showSearch; +} + + +/* + * Generic stateful widget for displaying paginated data retrieved via the API + */ +abstract class PaginatedSearchState extends State with BaseWidgetProperties { + static const _pageSize = 25; // Prefix for storing and loading pagination options // Override in implementing class String get prefix => "prefix_"; + // Return a map of boolean filtering options available for this list + // Should be overridden by an implementing subclass + Map> get filterOptions => {}; + + // Return the boolean value of a particular boolean filter + Future getBooleanFilterValue(String key) async { + key = "${prefix}bool_${key}"; + + Map opts = filterOptions[key] ?? {}; + + bool? backup; + dynamic v = opts["default"]; + + if (v is bool) { + backup = v; + } + + final result = await InvenTreeSettingsManager().getTriState(key, backup); + return result; + } + + // Set the boolean value of a particular boolean filter + Future setBooleanFilterValue(String key, bool? value) async { + key = "${prefix}bool_${key}"; + await InvenTreeSettingsManager().setValue(key, value); + } + + // Construct the boolean filter options for this list + Future> constructBooleanFilters() async { + + Map f = {}; + + for (String k in filterOptions.keys) { + bool? value = await getBooleanFilterValue(k); + + if (value is bool) { + f[k] = value ? "true" : "false"; + } + } + + return f; + } + // Return a map of sorting options available for this list // Should be overridden by an implementing subclass Map get orderingOptions => {}; @@ -115,6 +164,34 @@ class PaginatedSearchState extends State with BaseW } }; + // Add in boolean filter options + for (String key in filterOptions.keys) { + Map opts = filterOptions[key] ?? {}; + + // Determine field information + String label = (opts["label"] ?? key) as String; + String? help_text = opts["help_text"] as String?; + + bool tristate = (opts["tristate"] ?? true) as bool; + + bool? v = await getBooleanFilterValue(key); + + // Prevent null value if not tristate + if (!tristate && v == null) { + v = false; + } + + // Add in the particular field + fields[key] = { + "type": "boolean", + "display_name": label, + "label": label, + "help_text": help_text, + "value": v, + "tristate": (opts["tristate"] ?? true) as bool, + }; + } + // Launch an interactive form for the user to select options launchApiForm( context, @@ -132,6 +209,20 @@ class PaginatedSearchState extends State with BaseW await InvenTreeSettingsManager().setValue("${prefix}ordering_field", f); await InvenTreeSettingsManager().setValue("${prefix}ordering_order", o); + // Save boolean fields + for (String key in filterOptions.keys) { + + bool? v; + + dynamic value = data[key]; + + if (value is bool) { + v = value; + } + + await setBooleanFilterValue(key, v); + } + // Refresh data from the server _pagingController.refresh(); } @@ -189,10 +280,12 @@ class PaginatedSearchState extends State with BaseW */ Future _fetchPage(int pageKey) async { try { - Map params = filters; + Map params = widget.filters; // Include user search term - params["search"] = "${searchTerm}"; + if (searchTerm.isNotEmpty) { + params["search"] = "${searchTerm}"; + } // Use custom query ordering if available String o = await orderingString; @@ -200,12 +293,24 @@ class PaginatedSearchState extends State with BaseW params["ordering"] = o; } + Map f = await constructBooleanFilters(); + + if (f.isNotEmpty) { + params.addAll(f); + } + final page = await requestPage( _pageSize, pageKey, params ); + // We may have disposed of the widget while the request was in progress + // If this is the case, abort + if (!mounted) { + return; + } + int pageLength = page?.length ?? 0; int pageCount = page?.count ?? 0; @@ -263,32 +368,39 @@ class PaginatedSearchState extends State with BaseW @override Widget build (BuildContext context) { + List children = []; + + if (widget.showSearch) { + children.add(buildSearchInput(context)); + } + + children.add( + Expanded( + child: CustomScrollView( + shrinkWrap: true, + physics: ClampingScrollPhysics(), + scrollDirection: Axis.vertical, + slivers: [ + PagedSliverList.separated( + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) { + return buildItem(context, item); + }, + noItemsFoundIndicatorBuilder: (context) { + return NoResultsWidget(noResultsText); + } + ), + separatorBuilder: (context, item) => const Divider(height: 1), + ) + ] + ) + ) + ); + return Column( mainAxisAlignment: MainAxisAlignment.start, - children: [ - buildSearchInput(context), - Expanded( - child: CustomScrollView( - shrinkWrap: true, - physics: ClampingScrollPhysics(), - scrollDirection: Axis.vertical, - slivers: [ - PagedSliverList.separated( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) { - return buildItem(context, item); - }, - noItemsFoundIndicatorBuilder: (context) { - return NoResultsWidget(noResultsText); - } - ), - separatorBuilder: (context, item) => const Divider(height: 1), - ) - ] - ) - ) - ] + children: children, ); } diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index 4302565f..8f021f1c 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -606,7 +606,8 @@ class _PartDisplayState extends RefreshableState { ); case 1: return PaginatedStockItemList( - {"part": "${part.pk}"} + {"part": "${part.pk}"}, + true, ); case 2: return Center( diff --git a/lib/widget/part_list.dart b/lib/widget/part_list.dart index fcec2c00..37c4dc22 100644 --- a/lib/widget/part_list.dart +++ b/lib/widget/part_list.dart @@ -1,13 +1,15 @@ import "package:flutter/material.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; + +import "package:inventree/api.dart"; +import "package:inventree/l10.dart"; import "package:inventree/inventree/model.dart"; import "package:inventree/inventree/part.dart"; + import "package:inventree/widget/paginator.dart"; import "package:inventree/widget/part_detail.dart"; import "package:inventree/widget/refreshable_state.dart"; -import "package:inventree/api.dart"; -import "package:inventree/preferences.dart"; -import "package:inventree/l10.dart"; class PartList extends StatefulWidget { @@ -31,35 +33,43 @@ class _PartListState extends RefreshableState { final Map filters; + bool showFilterOptions = false; + @override String getAppBarTitle(BuildContext context) => title.isNotEmpty ? title : L10().parts; + @override + List getAppBarActions(BuildContext context) => [ + IconButton( + icon: FaIcon(FontAwesomeIcons.filter), + onPressed: () async { + setState(() { + showFilterOptions = !showFilterOptions; + }); + }, + ) + ]; + @override Widget getBody(BuildContext context) { - return PaginatedPartList(filters); + return PaginatedPartList(filters, showFilterOptions); } } -class PaginatedPartList extends StatefulWidget { +class PaginatedPartList extends PaginatedSearchWidget { - const PaginatedPartList(this.filters, {this.onTotalChanged}); - - final Map filters; - - final Function(int)? onTotalChanged; + const PaginatedPartList(Map filters, bool showSearch) : super(filters: filters, showSearch: showSearch); @override - _PaginatedPartListState createState() => _PaginatedPartListState(filters, onTotalChanged); + _PaginatedPartListState createState() => _PaginatedPartListState(); } class _PaginatedPartListState extends PaginatedSearchState { - _PaginatedPartListState(Map filters, this.onTotalChanged) : super(filters); - - Function(int)? onTotalChanged; + _PaginatedPartListState() : super(); @override String get prefix => "part_"; @@ -71,13 +81,42 @@ class _PaginatedPartListState extends PaginatedSearchState { "IPN": L10().internalPartNumber, }; + @override + Map> get filterOptions => { + "cascade": { + "default": true, + "label": L10().includeSubcategories, + "help_text": L10().includeSubcategoriesDetail, + }, + "active": { + "label": L10().filterActive, + "help_text": L10().filterActiveDetail, + }, + "assembly": { + "label": L10().filterAssembly, + "help_text": L10().filterAssemblyDetail + }, + "component": { + "label": L10().filterComponent, + "help_text": L10().filterComponentDetail, + }, + "is_template": { + "label": L10().filterTemplate, + "help_text": L10().filterTemplateDetail + }, + "virtual": { + "label": L10().filterVirtual, + "help_text": L10().filterVirtualDetail, + }, + "has_stock": { + "label": L10().filterInStock, + "help_text": L10().filterInStockDetail, + } + }; + @override Future requestPage(int limit, int offset, Map params) async { - final bool cascade = await InvenTreeSettingsManager().getBool(INV_PART_SUBCATEGORY, true); - - params["cascade"] = "${cascade}"; - final page = await InvenTreePart().listPaginated(limit, offset, filters: params); return page; diff --git a/lib/widget/purchase_order_detail.dart b/lib/widget/purchase_order_detail.dart index 92f71459..49cf9db6 100644 --- a/lib/widget/purchase_order_detail.dart +++ b/lib/widget/purchase_order_detail.dart @@ -391,7 +391,7 @@ class _PurchaseOrderDetailState extends RefreshableState filters; + bool showFilterOptions = false; + @override String getAppBarTitle(BuildContext context) => L10().purchaseOrders; + @override + List getAppBarActions(BuildContext context) => [ + IconButton( + icon: FaIcon(FontAwesomeIcons.filter), + onPressed: () async { + setState(() { + showFilterOptions = !showFilterOptions; + }); + }, + ) + ]; + @override Widget getBody(BuildContext context) { - return PaginatedPurchaseOrderList(filters); + return PaginatedPurchaseOrderList(filters, showFilterOptions); } } -class PaginatedPurchaseOrderList extends StatefulWidget { +class PaginatedPurchaseOrderList extends PaginatedSearchWidget { - const PaginatedPurchaseOrderList(this.filters); - - final Map filters; + const PaginatedPurchaseOrderList(Map filters, bool showSearch) : super(filters: filters, showSearch: showSearch); @override - _PaginatedPurchaseOrderListState createState() => _PaginatedPurchaseOrderListState(filters); + _PaginatedPurchaseOrderListState createState() => _PaginatedPurchaseOrderListState(); } class _PaginatedPurchaseOrderListState extends PaginatedSearchState { - _PaginatedPurchaseOrderListState(Map filters) : super(filters); - + _PaginatedPurchaseOrderListState() : super(); // Purchase order prefix String _poPrefix = ""; diff --git a/lib/widget/refreshable_state.dart b/lib/widget/refreshable_state.dart index dc29c7de..0d280de5 100644 --- a/lib/widget/refreshable_state.dart +++ b/lib/widget/refreshable_state.dart @@ -9,9 +9,7 @@ import "package:flutter/material.dart"; mixin BaseWidgetProperties { // Return a list of appBar actions (default = None) - List getAppBarActions(BuildContext context) { - return []; - } + List getAppBarActions(BuildContext context) => []; // Return a title for the appBar String getAppBarTitle(BuildContext context) { return "--- app bar ---"; } diff --git a/lib/widget/search.dart b/lib/widget/search.dart index dcbe47e8..c8078187 100644 --- a/lib/widget/search.dart +++ b/lib/widget/search.dart @@ -1,17 +1,19 @@ import "dart:async"; import "package:flutter/material.dart"; - import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:inventree/api.dart"; +import "package:inventree/app_colors.dart"; +import "package:inventree/l10.dart"; + +import "package:inventree/inventree/part.dart"; import "package:inventree/inventree/purchase_order.dart"; +import "package:inventree/inventree/stock.dart"; + import "package:inventree/widget/part_list.dart"; import "package:inventree/widget/purchase_order_list.dart"; import "package:inventree/widget/refreshable_state.dart"; -import "package:inventree/api.dart"; -import "package:inventree/l10.dart"; -import "package:inventree/inventree/part.dart"; -import "package:inventree/inventree/stock.dart"; import "package:inventree/widget/stock_list.dart"; import "package:inventree/widget/category_list.dart"; import "package:inventree/widget/company_list.dart"; @@ -222,31 +224,32 @@ class _SearchDisplayState extends RefreshableState { // Search input tiles.add( - TextFormField( - decoration: InputDecoration( - hintText: L10().queryEmpty, - prefixIcon: IconButton( - icon: FaIcon(FontAwesomeIcons.search), - onPressed: null, - ), - suffixIcon: IconButton( - icon: FaIcon(FontAwesomeIcons.backspace, color: Colors.red), - onPressed: () { - searchController.clear(); - onSearchTextChanged("", immediate: true); - _focusNode.requestFocus(); - } + ListTile( + title: TextFormField( + decoration: InputDecoration( + hintText: L10().queryEmpty, ), + readOnly: false, + autofocus: true, + autocorrect: false, + focusNode: _focusNode, + controller: searchController, + onChanged: (String text) { + onSearchTextChanged(text); + }, ), - readOnly: false, - autofocus: true, - autocorrect: false, - focusNode: _focusNode, - controller: searchController, - onChanged: (String text) { - onSearchTextChanged(text); - }, - ), + trailing: GestureDetector( + child: FaIcon( + searchController.text.isEmpty ? FontAwesomeIcons.search : FontAwesomeIcons.backspace, + color: searchController.text.isEmpty ? COLOR_CLICK : COLOR_DANGER, + ), + onTap: () { + searchController.clear(); + onSearchTextChanged("", immediate: true); + }, + ), + ) + ); String query = searchController.text; @@ -406,8 +409,11 @@ class _SearchDisplayState extends RefreshableState { if (!isSearching() && results.isEmpty && searchController.text.isNotEmpty) { tiles.add( ListTile( - title: Text(L10().queryNoResults), - leading: FaIcon(FontAwesomeIcons.search), + title: Text( + L10().queryNoResults, + style: TextStyle(fontStyle: FontStyle.italic), + ), + leading: FaIcon(FontAwesomeIcons.searchMinus), ) ); } else { diff --git a/lib/widget/stock_list.dart b/lib/widget/stock_list.dart index e4237bdf..7d7e5aee 100644 --- a/lib/widget/stock_list.dart +++ b/lib/widget/stock_list.dart @@ -1,11 +1,11 @@ import "package:flutter/material.dart"; +import "package:font_awesome_flutter/font_awesome_flutter.dart"; import "package:inventree/inventree/model.dart"; import "package:inventree/inventree/stock.dart"; import "package:inventree/widget/paginator.dart"; import "package:inventree/widget/refreshable_state.dart"; import "package:inventree/l10.dart"; -import "package:inventree/preferences.dart"; import "package:inventree/widget/stock_detail.dart"; import "package:inventree/api.dart"; @@ -27,30 +27,42 @@ class _StockListState extends RefreshableState { final Map filters; + bool showFilterOptions = false; + @override String getAppBarTitle(BuildContext context) => L10().stockItems; + @override + List getAppBarActions(BuildContext context) => [ + IconButton( + icon: FaIcon(FontAwesomeIcons.filter), + onPressed: () async { + setState(() { + showFilterOptions = !showFilterOptions; + }); + }, + ) + ]; + @override Widget getBody(BuildContext context) { - return PaginatedStockItemList(filters); + return PaginatedStockItemList(filters, showFilterOptions); } } -class PaginatedStockItemList extends StatefulWidget { +class PaginatedStockItemList extends PaginatedSearchWidget { - const PaginatedStockItemList(this.filters); - - final Map filters; + const PaginatedStockItemList(Map filters, bool showSearch) : super(filters: filters, showSearch: showSearch); @override - _PaginatedStockItemListState createState() => _PaginatedStockItemListState(filters); + _PaginatedStockItemListState createState() => _PaginatedStockItemListState(); } class _PaginatedStockItemListState extends PaginatedSearchState { - _PaginatedStockItemListState(Map filters) : super(filters); + _PaginatedStockItemListState() : super(); @override String get prefix => "stock_"; @@ -66,14 +78,23 @@ class _PaginatedStockItemListState extends PaginatedSearchState> get filterOptions => { + "cascade": { + "default": false, + "label": L10().includeSublocations, + "help_text": L10().includeSublocationsDetail, + "tristate": false, + }, + "serialized": { + "label": L10().filterSerialized, + "help_text": L10().filterSerializedDetail, + } + }; + @override Future requestPage(int limit, int offset, Map params) async { - // Do we include stock items from sub-locations? - final bool cascade = await InvenTreeSettingsManager().getBool(INV_STOCK_SUBLOCATION, true); - - params["cascade"] = "${cascade}"; - final page = await InvenTreeStockItem().listPaginated( limit, offset, diff --git a/test/models_test.dart b/test/models_test.dart index 4725c5ac..213ff56c 100644 --- a/test/models_test.dart +++ b/test/models_test.dart @@ -63,7 +63,11 @@ void main() { // List *all* parts results = await InvenTreePart().list(); - assert(results.length == 13); + expect(results.length, equals(14)); + + // List with active filter + results = await InvenTreePart().list(filters: {"active": "true"}); + expect(results.length, equals(13)); for (var result in results) { // results must be InvenTreePart instances diff --git a/test/preferences_test.dart b/test/preferences_test.dart index b402b7a9..72941591 100644 --- a/test/preferences_test.dart +++ b/test/preferences_test.dart @@ -8,7 +8,6 @@ import "package:inventree/preferences.dart"; void main() { setUp(() async { - }); group("Settings Tests:", () { @@ -26,5 +25,44 @@ void main() { expect(await InvenTreeSettingsManager().getValue("abc", "123"), equals("xyz")); }); + + test("Booleans", () async { + // Tests for boolean values + + await InvenTreeSettingsManager().removeValue("chicken"); + + // Use default values when a setting does not exist + assert(await InvenTreeSettingsManager().getBool("chicken", true) == true); + assert(await InvenTreeSettingsManager().getBool("chicken", false) == false); + + // Explicitly set to true + await InvenTreeSettingsManager().setValue("chicken", true); + assert(await InvenTreeSettingsManager().getBool("chicken", false) == true); + + // Explicitly set to false + await InvenTreeSettingsManager().setValue("chicken", false); + assert(await InvenTreeSettingsManager().getBool("chicken", true) == false); + + }); + + test("Tri State", () async { + // Tests for tristate values + + await InvenTreeSettingsManager().removeValue("dog"); + + // Use default values when a setting does not exist + for (bool? value in [true, false, null]) { + assert(await InvenTreeSettingsManager().getTriState("dog", value) == value); + } + + // Explicitly set to a value + for (bool? value in [true, false, null]) { + await InvenTreeSettingsManager().setValue("dog", value); + + assert(await InvenTreeSettingsManager().getTriState("dog", true) == value); + assert(await InvenTreeSettingsManager().getTriState("dog", false) == value); + assert(await InvenTreeSettingsManager().getTriState("dog", null) == value); + } + }); }); } \ No newline at end of file