2
0
mirror of https://github.com/inventree/inventree-app.git synced 2025-04-28 05:26:47 +00:00

List refactor (#179)

* Catch paginator bug if widget is disposed before request returns

* Refactoring paginated query widget

- Add option to enable / disable search filters

* Major refactor of paginated search widget

- Learned something new.. a state can access widget.<attribute>
- THIS CHANGES EVERTHING

* Preferences: Add code for tri-state values

- Also improve unit testing for preferences code

* Allow boolean form fields to be optionally tristate

* paginator: Allow custom boolean filters

* Remove outdated filtering preferences

* Refactor filter options

- Allow specification of more detailed options

* Add custom filters for "part" list

* filter tweaks

* Remove legacy "SublocationList" widget

* Add filtering option for locationlist

* Updates for stock location widget

* Refactor category display widget

* More widget refactoring

* Update main search widget

* Fix unit tests

* Improve filtering on BOM display page
This commit is contained in:
Oliver 2022-07-19 23:29:01 +10:00 committed by GitHub
parent e03a8561b9
commit 13ebaf43e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 667 additions and 403 deletions

View File

@ -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;
},

View File

@ -32,15 +32,6 @@ class InvenTreePartCategory extends InvenTreeModel {
};
}
@override
Map<String, String> 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<String, String> defaultListFilters() {
return {
"cascade": "false",
"active": "true",
"location_detail": "true",
};
}

View File

@ -224,7 +224,6 @@ class InvenTreeStockItem extends InvenTreeModel {
"part_detail": "true",
"location_detail": "true",
"supplier_detail": "true",
"cascade": "false",
"in_stock": "true",
};
}

View File

@ -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",

View File

@ -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<Database> get _db async => InvenTreePreferencesDB.instance.database;
Future<void> removeValue(String key) async {
await store.record(key).delete(await _db);
}
Future<dynamic> 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<bool?> 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<void> setValue(String key, dynamic value) async {
// Encode null values as strings
value ??= "null";
await store.record(key).put(await _db, value);
}

View File

@ -21,11 +21,7 @@ class _InvenTreeAppSettingsState extends State<InvenTreeAppSettingsWidget> {
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<InvenTreeAppSettingsWidget> {
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<InvenTreeAppSettingsWidget> {
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),

View File

@ -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<BillOfMaterialsWidget> {
final InvenTreePart part;
bool showFilterOptions = false;
@override
String getAppBarTitle(BuildContext context) => L10().billOfMaterials;
@override
List<Widget> 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<BillOfMaterialsWidget> {
/*
* 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<String, String> filters;
final Function(int)? onTotalChanged;
const PaginatedBomList(Map<String, String> filters, bool showSearch) : super(filters: filters, showSearch: showSearch);
@override
_PaginatedBomListState createState() => _PaginatedBomListState(filters, onTotalChanged);
_PaginatedBomListState createState() => _PaginatedBomListState();
}
class _PaginatedBomListState extends PaginatedSearchState<PaginatedBomList> {
_PaginatedBomListState(Map<String, String> filters, this.onTotalChanged) : super(filters);
Function(int)? onTotalChanged;
_PaginatedBomListState() : super();
@override
String get prefix => "bom_";
@ -77,6 +87,14 @@ class _PaginatedBomListState extends PaginatedSearchState<PaginatedBomList> {
"sub_part": L10().part,
};
@override
Map<String, Map<String, dynamic>> get filterOptions => {
"sub_part_assembly": {
"label": L10().filterAssembly,
"help_text": L10().filterAssemblyDetail,
}
};
@override
Future<InvenTreePageResponse?> requestPage(int limit, int offset, Map<String, String> params) async {

View File

@ -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<CategoryDisplayWidget> {
_CategoryDisplayState(this.category);
bool showFilterOptions = false;
@override
String getAppBarTitle(BuildContext context) => L10().partCategory;
@ -73,8 +76,6 @@ class _CategoryDisplayState extends RefreshableState<CategoryDisplayWidget> {
// The local InvenTreePartCategory object
final InvenTreePartCategory? category;
List<InvenTreePartCategory> _subcategories = [];
@override
Future<void> onBuild(BuildContext context) async {
refresh(context);
@ -83,8 +84,6 @@ class _CategoryDisplayState extends RefreshableState<CategoryDisplayWidget> {
@override
Future<void> 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<CategoryDisplayWidget> {
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<CategoryDisplayWidget> {
);
}
// Construct the "details" panel
List<Widget> detailTiles() {
List<Widget> tiles = <Widget>[
getCategoryDescriptionCard(),
ListTile(
@ -190,27 +181,62 @@ class _CategoryDisplayState extends RefreshableState<CategoryDisplayWidget> {
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<Widget> partsTiles() {
Map<String, String> 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<void> _newCategory(BuildContext context) async {
int pk = category?.pk ?? -1;
@ -323,14 +349,12 @@ class _CategoryDisplayState extends RefreshableState<CategoryDisplayWidget> {
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<CategoryDisplayWidget> {
}
}
}
/*
* Builder for displaying a list of PartCategory objects
*/
class SubcategoryList extends StatelessWidget {
const SubcategoryList(this._categories);
final List<InvenTreePartCategory> _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);
}
}

View File

@ -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<PartCategoryList> {
final Map<String, String> filters;
bool showFilterOptions = false;
@override
List<Widget> 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<String, String> filters;
const PaginatedPartCategoryList(Map<String, String> filters, bool showSearch) : super(filters: filters, showSearch: showSearch);
@override
_PaginatedPartCategoryListState createState() => _PaginatedPartCategoryListState(filters);
_PaginatedPartCategoryListState createState() => _PaginatedPartCategoryListState();
}
class _PaginatedPartCategoryListState extends PaginatedSearchState<PaginatedPartCategoryList> {
_PaginatedPartCategoryListState(Map<String, String> filters) : super(filters);
// _PaginatedPartCategoryListState(Map<String, String> filters, bool searchEnabled) : super(filters, searchEnabled);
@override
String get prefix => "category_";
@override
Map<String, Map<String, dynamic>> get filterOptions => {
"cascade": {
"default": false,
"label": L10().includeSubcategories,
"help_text": L10().includeSubcategoriesDetail,
"tristate": false,
}
};
@override
Map<String, String> get orderingOptions => {
"name": L10().name,

View File

@ -35,29 +35,22 @@ class _CompanyListWidgetState extends RefreshableState<CompanyListWidget> {
@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<String, String> filters;
final Function(int)? onTotalChanged;
const PaginatedCompanyList(Map<String, String> filters, bool showSearch) : super(filters: filters, showSearch: showSearch);
@override
_CompanyListState createState() => _CompanyListState(filters);
_CompanyListState createState() => _CompanyListState();
}
class _CompanyListState extends PaginatedSearchState<PaginatedCompanyList> {
_CompanyListState(Map<String, String> filters) : super(filters);
_CompanyListState() : super();
@override
Future<InvenTreePageResponse?> requestPage(int limit, int offset, Map<String, String> params) async {

View File

@ -142,7 +142,10 @@ class FilePickerDialog {
class CheckBoxField extends FormField<bool> {
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<bool> {
//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,

View File

@ -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<LocationDisplayWidget> {
final InvenTreeStockLocation? location;
bool showFilterOptions = false;
@override
String getAppBarTitle(BuildContext context) { return L10().stockLocation; }
@ -103,19 +110,6 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> {
);
}
List<InvenTreeStockLocation> _sublocations = [];
String _locationFilter = "";
List<InvenTreeStockLocation> get sublocations {
if (_locationFilter.isEmpty || _sublocations.isEmpty) {
return _sublocations;
} else {
return _sublocations.where((loc) => loc.filter(_locationFilter)).toList();
}
}
@override
Future<void> onBuild(BuildContext context) async {
refresh(context);
@ -124,8 +118,6 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> {
@override
Future<void> 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<LocationDisplayWidget> {
}
}
// 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<LocationDisplayWidget> {
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<LocationDisplayWidget> {
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<LocationDisplayWidget> {
Widget getSelectedWidget(int index) {
// Construct filters for paginated stock list
Map<String, String> 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<LocationDisplayWidget> {
return getSelectedWidget(tabIndex);
}
List<Widget> detailTiles() {
// Construct the "details" panel
List<Widget> detailTiles() {
List<Widget> tiles = [
locationDescriptionCard(),
ListTile(
@ -326,27 +306,61 @@ List<Widget> 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<Widget> stockTiles() {
Map<String, String> 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<Widget> actionTiles() {
List<Widget> tiles = [];
@ -452,48 +466,4 @@ List<Widget> detailTiles() {
return tiles;
}
}
class SublocationList extends StatelessWidget {
const SublocationList(this._locations);
final List<InvenTreeStockLocation> _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
);
}
}

View File

@ -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<StockLocationList> {
final Map<String, String> filters;
bool showFilterOptions = false;
@override
List<Widget> 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<String, String> filters;
const PaginatedStockLocationList(Map<String, String> filters, bool showSearch) : super(filters: filters, showSearch: showSearch);
@override
_PaginatedStockLocationListState createState() => _PaginatedStockLocationListState(filters);
_PaginatedStockLocationListState createState() => _PaginatedStockLocationListState();
}
class _PaginatedStockLocationListState extends PaginatedSearchState<PaginatedStockLocationList> {
_PaginatedStockLocationListState(Map<String, String> filters) : super(filters);
_PaginatedStockLocationListState() : super();
@override
Map<String, String> get orderingOptions => {
"name": L10().name,
"level": L10().level,
};
@override
Map<String, Map<String, dynamic>> get filterOptions => {
"cascade": {
"label": L10().includeSublocations,
"help_text": L10().includeSublocationsDetail,
"tristate": false,
}
};
@override
Future<InvenTreePageResponse?> requestPage(int limit, int offset, Map<String, String> params) async {

View File

@ -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<T extends StatefulWidget> extends State<T> with BaseWidgetProperties {
abstract class PaginatedSearchWidget extends StatefulWidget {
PaginatedSearchState(this.filters);
const PaginatedSearchWidget({this.filters = const {}, this.showSearch = false});
final Map<String, String> filters;
final bool showSearch;
}
/*
* Generic stateful widget for displaying paginated data retrieved via the API
*/
abstract class PaginatedSearchState<T extends PaginatedSearchWidget> extends State<T> 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<String, Map<String, dynamic>> get filterOptions => {};
// Return the boolean value of a particular boolean filter
Future<bool?> getBooleanFilterValue(String key) async {
key = "${prefix}bool_${key}";
Map<String, dynamic> 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<void> setBooleanFilterValue(String key, bool? value) async {
key = "${prefix}bool_${key}";
await InvenTreeSettingsManager().setValue(key, value);
}
// Construct the boolean filter options for this list
Future<Map<String, String>> constructBooleanFilters() async {
Map<String, String> 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<String, String> get orderingOptions => {};
@ -115,6 +164,34 @@ class PaginatedSearchState<T extends StatefulWidget> extends State<T> with BaseW
}
};
// Add in boolean filter options
for (String key in filterOptions.keys) {
Map<String, dynamic> 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<T extends StatefulWidget> extends State<T> 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<T extends StatefulWidget> extends State<T> with BaseW
*/
Future<void> _fetchPage(int pageKey) async {
try {
Map<String, String> params = filters;
Map<String, String> 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<T extends StatefulWidget> extends State<T> with BaseW
params["ordering"] = o;
}
Map<String, String> 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<T extends StatefulWidget> extends State<T> with BaseW
@override
Widget build (BuildContext context) {
List<Widget> children = [];
if (widget.showSearch) {
children.add(buildSearchInput(context));
}
children.add(
Expanded(
child: CustomScrollView(
shrinkWrap: true,
physics: ClampingScrollPhysics(),
scrollDirection: Axis.vertical,
slivers: <Widget>[
PagedSliverList.separated(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<InvenTreeModel>(
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: <Widget>[
PagedSliverList.separated(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<InvenTreeModel>(
itemBuilder: (context, item, index) {
return buildItem(context, item);
},
noItemsFoundIndicatorBuilder: (context) {
return NoResultsWidget(noResultsText);
}
),
separatorBuilder: (context, item) => const Divider(height: 1),
)
]
)
)
]
children: children,
);
}

View File

@ -606,7 +606,8 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
);
case 1:
return PaginatedStockItemList(
{"part": "${part.pk}"}
{"part": "${part.pk}"},
true,
);
case 2:
return Center(

View File

@ -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<PartList> {
final Map<String, String> filters;
bool showFilterOptions = false;
@override
String getAppBarTitle(BuildContext context) => title.isNotEmpty ? title : L10().parts;
@override
List<Widget> 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<String, String> filters;
final Function(int)? onTotalChanged;
const PaginatedPartList(Map<String, String> filters, bool showSearch) : super(filters: filters, showSearch: showSearch);
@override
_PaginatedPartListState createState() => _PaginatedPartListState(filters, onTotalChanged);
_PaginatedPartListState createState() => _PaginatedPartListState();
}
class _PaginatedPartListState extends PaginatedSearchState<PaginatedPartList> {
_PaginatedPartListState(Map<String, String> filters, this.onTotalChanged) : super(filters);
Function(int)? onTotalChanged;
_PaginatedPartListState() : super();
@override
String get prefix => "part_";
@ -71,13 +81,42 @@ class _PaginatedPartListState extends PaginatedSearchState<PaginatedPartList> {
"IPN": L10().internalPartNumber,
};
@override
Map<String, Map<String, dynamic>> 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<InvenTreePageResponse?> requestPage(int limit, int offset, Map<String, String> 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;

View File

@ -391,7 +391,7 @@ class _PurchaseOrderDetailState extends RefreshableState<PurchaseOrderDetailWidg
"purchase_order": "${order.pk}"
};
return PaginatedStockItemList(filters);
return PaginatedStockItemList(filters, true);
default:
return ListView();

View File

@ -1,4 +1,5 @@
import "package:flutter/material.dart";
import "package:font_awesome_flutter/font_awesome_flutter.dart";
import "package:inventree/inventree/company.dart";
import "package:inventree/inventree/model.dart";
@ -29,32 +30,43 @@ class _PurchaseOrderListWidgetState extends RefreshableState<PurchaseOrderListWi
final Map<String, String> filters;
bool showFilterOptions = false;
@override
String getAppBarTitle(BuildContext context) => L10().purchaseOrders;
@override
List<Widget> 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<String, String> filters;
const PaginatedPurchaseOrderList(Map<String, String> filters, bool showSearch) : super(filters: filters, showSearch: showSearch);
@override
_PaginatedPurchaseOrderListState createState() => _PaginatedPurchaseOrderListState(filters);
_PaginatedPurchaseOrderListState createState() => _PaginatedPurchaseOrderListState();
}
class _PaginatedPurchaseOrderListState extends PaginatedSearchState<PaginatedPurchaseOrderList> {
_PaginatedPurchaseOrderListState(Map<String, String> filters) : super(filters);
_PaginatedPurchaseOrderListState() : super();
// Purchase order prefix
String _poPrefix = "";

View File

@ -9,9 +9,7 @@ import "package:flutter/material.dart";
mixin BaseWidgetProperties {
// Return a list of appBar actions (default = None)
List<Widget> getAppBarActions(BuildContext context) {
return [];
}
List<Widget> getAppBarActions(BuildContext context) => [];
// Return a title for the appBar
String getAppBarTitle(BuildContext context) { return "--- app bar ---"; }

View File

@ -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<SearchWidget> {
// 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<SearchWidget> {
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 {

View File

@ -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<StockItemList> {
final Map<String, String> filters;
bool showFilterOptions = false;
@override
String getAppBarTitle(BuildContext context) => L10().stockItems;
@override
List<Widget> 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<String, String> filters;
const PaginatedStockItemList(Map<String, String> filters, bool showSearch) : super(filters: filters, showSearch: showSearch);
@override
_PaginatedStockItemListState createState() => _PaginatedStockItemListState(filters);
_PaginatedStockItemListState createState() => _PaginatedStockItemListState();
}
class _PaginatedStockItemListState extends PaginatedSearchState<PaginatedStockItemList> {
_PaginatedStockItemListState(Map<String, String> filters) : super(filters);
_PaginatedStockItemListState() : super();
@override
String get prefix => "stock_";
@ -66,14 +78,23 @@ class _PaginatedStockItemListState extends PaginatedSearchState<PaginatedStockIt
"stocktake_date": L10().lastStocktake,
};
@override
Map<String, Map<String, dynamic>> get filterOptions => {
"cascade": {
"default": false,
"label": L10().includeSublocations,
"help_text": L10().includeSublocationsDetail,
"tristate": false,
},
"serialized": {
"label": L10().filterSerialized,
"help_text": L10().filterSerializedDetail,
}
};
@override
Future<InvenTreePageResponse?> requestPage(int limit, int offset, Map<String, String> 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,

View File

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

View File

@ -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);
}
});
});
}