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

Work on refactoring search page

- Implement generic "search" count preview
- Nice and speedy!
This commit is contained in:
Oliver 2021-10-03 23:37:15 +11:00
parent 98e0106a81
commit 1ca3732a33
5 changed files with 276 additions and 354 deletions

View File

@ -185,6 +185,34 @@ class InvenTreeModel {
}
// Return the number of results that would meet a particular "query"
Future<int> count({Map<String, String> filters = const {}, String search = ""} ) async {
var params = defaultListFilters();
filters.forEach((String key, String value) {
params[key] = value;
});
if (search.isNotEmpty) {
params["search"] = search;
}
// Limit to 1 result, for quick DB access
params["limit"] = "1";
var response = await api.get(URL, params: params);
if (response.isValid()) {
int n = int.tryParse(response.data["count"].toString()) ?? 0;
print("${URL} -> ${n} results");
return n;
} else {
return 0;
}
}
Map<String, String> defaultListFilters() {
return {};
}

@ -1 +1 @@
Subproject commit 5f30fef900ea027cd7fe90b2d87a6f02ced315c0
Subproject commit d004dc013edfc47b5ed94c5b019a013dc5ef444a

View File

@ -5,6 +5,7 @@ import "package:inventree/l10.dart";
import "package:inventree/settings/settings.dart";
import "package:font_awesome_flutter/font_awesome_flutter.dart";
import "package:inventree/widget/search.dart";
class InvenTreeDrawer extends StatelessWidget {
@ -35,7 +36,12 @@ class InvenTreeDrawer extends StatelessWidget {
_closeDrawer();
// TODO: Open search dialog
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SearchWidget()
)
);
}
/*
@ -59,6 +65,7 @@ class InvenTreeDrawer extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Drawer(
child: ListView(
children: ListTile.divideTiles(

View File

@ -17,6 +17,7 @@ import "package:inventree/widget/category_display.dart";
import "package:inventree/widget/company_list.dart";
import "package:inventree/widget/location_display.dart";
import "package:inventree/widget/purchase_order_list.dart";
import 'package:inventree/widget/search.dart';
import "package:inventree/widget/snacks.dart";
import "package:inventree/widget/drawer.dart";
@ -42,7 +43,15 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
UserProfile? _profile;
void _search(BuildContext context) {
// TODO
if (!InvenTreeAPI().checkConnection(context)) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SearchWidget()
)
);
}
void _scan(BuildContext context) {

View File

@ -1,392 +1,270 @@
import "dart:async";
import 'package:inventree/inventree/company.dart';
import 'package:inventree/inventree/purchase_order.dart';
import "package:inventree/widget/part_detail.dart";
import "package:inventree/widget/progress.dart";
import 'package:inventree/widget/refreshable_state.dart';
import "package:inventree/widget/snacks.dart";
import "package:inventree/widget/stock_detail.dart";
import "package:flutter/cupertino.dart";
import "package:flutter/material.dart";
import "package:font_awesome_flutter/font_awesome_flutter.dart";
import "package:inventree/l10.dart";
import "package:inventree/inventree/part.dart";
import "package:inventree/inventree/stock.dart";
import "package:inventree/api.dart";
// TODO - Refactor duplicate code in this file!
class PartSearchDelegate extends SearchDelegate<InvenTreePart?> {
PartSearchDelegate(this.context, {Map<String, String> filters = const {}}) {
// Copy filter values
for (String key in filters.keys) {
String? value = filters[key];
if (value != null) {
_filters[key] = value;
}
}
}
final partSearchKey = GlobalKey<ScaffoldState>();
BuildContext context;
// What did we search for last time?
String _cachedQuery = "";
bool _searching = false;
// Custom filters for the part search
Map<String, String> _filters = {};
// Widget for performing database-wide search
class SearchWidget extends StatefulWidget {
@override
String get searchFieldLabel => L10().searchParts;
_SearchDisplayState createState() => _SearchDisplayState();
// List of part results
List<InvenTreePart> partResults = [];
Future<void> search(BuildContext context) async {
// Search string too short!
if (query.length < 3) {
partResults.clear();
showResults(context);
return;
}
if (query == _cachedQuery) {
return;
}
_cachedQuery = query;
_searching = true;
print("Searching...");
showResults(context);
_filters["cascade"] = "true";
final results = await InvenTreePart().search(query, filters: _filters);
partResults.clear();
for (int idx = 0; idx < results.length; idx++) {
if (results[idx] is InvenTreePart) {
partResults.add(results[idx] as InvenTreePart);
}
}
print("Searching complete! Results: ${partResults.length}");
_searching = false;
showSnackIcon(
"${partResults.length} ${L10().results}",
success: partResults.isNotEmpty,
icon: FontAwesomeIcons.pollH,
);
// For some reason, need to toggle between suggestions and results here...
showSuggestions(context);
showResults(context);
}
class _SearchDisplayState extends RefreshableState<SearchWidget> {
@override
List<Widget> buildActions(BuildContext context) {
return [
IconButton(
icon: FaIcon(FontAwesomeIcons.backspace),
onPressed: () {
query = "";
search(context);
},
),
IconButton(
icon: FaIcon(FontAwesomeIcons.search),
onPressed: () {
search(context);
}
),
];
String getAppBarTitle(BuildContext context) => L10().search;
final TextEditingController searchController = TextEditingController();
Timer? debounceTimer;
int nPartResults = 0;
int nCategoryResults = 0;
int nStockResults = 0;
int nLocationResults = 0;
int nSupplierResults = 0;
int nPurchaseOrderResults = 0;
// Callback when the text is being edited
// Incorporates a debounce timer to restrict search frequency
void onSearchTextChanged(String text, {bool immediate = false}) {
if (debounceTimer?.isActive ?? false) {
debounceTimer!.cancel();
}
@override
Widget buildLeading(BuildContext context) {
return IconButton(
icon: Icon(Icons.arrow_back),
onPressed: () {
close(context, null);
}
);
}
Widget _partResult(BuildContext context, int index) {
InvenTreePart part = partResults[index];
return ListTile(
title: Text(part.fullname),
subtitle: Text(part.description),
leading: InvenTreeAPI().getImage(
part.thumbnail,
width: 40,
height: 40
),
trailing: Text(part.inStockString),
onTap: () {
InvenTreePart().get(part.pk).then((var prt) {
if (prt is InvenTreePart) {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => PartDetailWidget(prt))
);
}
if (immediate) {
search(text);
} else {
debounceTimer = Timer(Duration(milliseconds: 250), () {
search(text);
});
}
);
}
@override
Widget buildResults(BuildContext context) {
Future<void> search(String term) async {
print("build results");
if (term.isEmpty) {
setState(() {
// Do not search on an empty string
nPartResults = 0;
nCategoryResults = 0;
nStockResults = 0;
nLocationResults = 0;
nSupplierResults = 0;
nPurchaseOrderResults = 0;
});
if (_searching) {
return progressIndicator();
}
search(context);
if (query.isEmpty) {
return ListTile(
title: Text(L10().queryEnter)
);
}
if (query.length < 3) {
return ListTile(
title: Text(L10().queryShort),
subtitle: Text(L10().queryShortDetail)
);
}
if (partResults.isEmpty) {
return ListTile(
title: Text(L10().noResults),
subtitle: Text(L10().queryNoResults + " '${query}'")
);
}
return ListView.separated(
shrinkWrap: true,
physics: ClampingScrollPhysics(),
separatorBuilder: (_, __) => const Divider(height: 3),
itemBuilder: _partResult,
itemCount: partResults.length,
);
}
@override
Widget buildSuggestions(BuildContext context) {
// TODO - Implement
return Column();
}
// Ensure the search theme matches the app theme
@override
ThemeData appBarTheme(BuildContext context) {
final ThemeData theme = Theme.of(context);
return theme;
}
}
class StockSearchDelegate extends SearchDelegate<InvenTreeStockItem?> {
StockSearchDelegate(this.context, {Map<String, String> filters = const {}}) {
// Copy filter values
for (String key in filters.keys) {
String? value = filters[key];
if (value != null) {
_filters[key] = value;
}
}
}
final stockSearchKey = GlobalKey<ScaffoldState>();
final BuildContext context;
String _cachedQuery = "";
bool _searching = false;
// Custom filters for the stock item search
Map<String, String> _filters = {};
@override
String get searchFieldLabel => L10().searchStock;
// List of StockItem results
List<InvenTreeStockItem> itemResults = [];
Future<void> search(BuildContext context) async {
// Search string too short!
if (query.length < 3) {
itemResults.clear();
showResults(context);
return;
}
if (query == _cachedQuery) {
return;
// Search parts
InvenTreePart().count(
search: term
).then((int n) {
setState(() {
nPartResults = n;
});
});
// Search part categories
InvenTreePartCategory().count(
search: term,
).then((int n) {
setState(() {
nCategoryResults = n;
});
});
// Search stock items
InvenTreeStockItem().count(
search: term
).then((int n) {
setState(() {
nStockResults = n;
});
});
// Search stock locations
InvenTreeStockLocation().count(
search: term
).then((int n) {
setState(() {
nLocationResults = n;
});
});
// Search suppliers
InvenTreeCompany().count(
search: term,
filters: {
"is_supplier": "true",
},
).then((int n) {
setState(() {
nSupplierResults = n;
});
});
// Search purchase orders
InvenTreePurchaseOrder().count(
search: term,
filters: {
"outstanding": "true"
}
).then((int n) {
setState(() {
nPurchaseOrderResults = n;
});
});
}
_cachedQuery = query;
List<Widget> _tiles(BuildContext context) {
_searching = true;
List<Widget> tiles = [];
print("Searching...");
showResults(context);
// Enable cascading part search by default
_filters["cascade"] = "true";
final results = await InvenTreeStockItem().search(query, filters: _filters);
itemResults.clear();
for (int idx = 0; idx < results.length; idx++) {
if (results[idx] is InvenTreeStockItem) {
itemResults.add(results[idx] as InvenTreeStockItem);
}
}
_searching = false;
showSnackIcon(
"${itemResults.length} ${L10().results}",
success: itemResults.isNotEmpty,
icon: FontAwesomeIcons.pollH,
);
showSuggestions(context);
showResults(context);
}
@override
List<Widget> buildActions(BuildContext context) {
return [
IconButton(
icon: FaIcon(FontAwesomeIcons.backspace),
onPressed: () {
query = "";
search(context);
// Search input
tiles.add(
InputDecorator(
decoration: InputDecoration(
),
child: ListTile(
title: TextField(
readOnly: false,
controller: searchController,
onChanged: (String text) {
onSearchTextChanged(text);
},
),
IconButton(
icon: FaIcon(FontAwesomeIcons.search),
trailing: IconButton(
icon: FaIcon(FontAwesomeIcons.backspace, color: Colors.red),
onPressed: () {
search(context);
}
searchController.clear();
onSearchTextChanged("", immediate: true);
},
),
];
}
@override
Widget buildLeading(BuildContext context) {
return IconButton(
icon: Icon(Icons.arrow_back),
onPressed: () {
close(context, null);
}
)
)
);
}
Widget _itemResult(BuildContext context, int index) {
List<Widget> results = [];
InvenTreeStockItem item = itemResults[index];
return ListTile(
title: Text(item.partName),
subtitle: Text(item.locationName),
leading: InvenTreeAPI().getImage(
item.partThumbnail,
width: 40,
height: 40,
),
trailing: Text(item.serialOrQuantityDisplay()),
// Part Results
if (nPartResults > 0) {
results.add(
ListTile(
title: Text(L10().parts),
leading: FaIcon(FontAwesomeIcons.shapes),
trailing: Text("${nPartResults}"),
onTap: () {
InvenTreeStockItem().get(item.pk).then((var it) {
if (it is InvenTreeStockItem) {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => StockDetailWidget(it))
// Show part results
}
)
);
}
});
}
// Part Category Results
if (nCategoryResults > 0) {
results.add(
ListTile(
title: Text(L10().partCategories),
leading: FaIcon(FontAwesomeIcons.sitemap),
trailing: Text("${nCategoryResults}"),
)
);
}
// Stock Item Results
if (nStockResults > 0) {
results.add(
ListTile(
title: Text(L10().stockItems),
leading: FaIcon(FontAwesomeIcons.boxes),
trailing: Text("${nStockResults}"),
)
);
}
// Stock location results
if (nLocationResults > 0) {
results.add(
ListTile(
title: Text(L10().stockLocations),
leading: FaIcon(FontAwesomeIcons.mapMarkerAlt),
trailing: Text("${nLocationResults}"),
)
);
}
// Suppliers
if (nSupplierResults > 0) {
results.add(
ListTile(
title: Text(L10().suppliers),
leading: FaIcon(FontAwesomeIcons.building),
trailing: Text("${nSupplierResults}")
)
);
}
// Purchase orders
if (nPurchaseOrderResults > 0) {
results.add(
ListTile(
title: Text(L10().purchaseOrders),
leading: FaIcon(FontAwesomeIcons.shoppingCart),
trailing: Text("${nPurchaseOrderResults}"),
)
);
}
if (results.isEmpty) {
tiles.add(
ListTile(
title: Text(L10().queryNoResults),
leading: FaIcon(FontAwesomeIcons.search),
)
);
} else {
for (Widget result in results) {
tiles.add(result);
}
}
return tiles;
}
@override
Widget buildResults(BuildContext context) {
search(context);
if (_searching) {
return progressIndicator();
}
search(context);
if (query.isEmpty) {
return ListTile(
title: Text(L10().queryEnter)
Widget getBody(BuildContext context) {
return Center(
child: ListView(
children: ListTile.divideTiles(
context: context,
tiles: _tiles(context),
).toList()
)
);
}
if (query.length < 3) {
return ListTile(
title: Text(L10().queryShort),
subtitle: Text(L10().queryShortDetail)
);
}
if (itemResults.isEmpty) {
return ListTile(
title: Text(L10().noResults),
subtitle: Text(L10().queryNoResults + " '${query}'")
);
}
return ListView.separated(
shrinkWrap: true,
physics: ClampingScrollPhysics(),
separatorBuilder: (_, __) => const Divider(height: 3),
itemBuilder: _itemResult,
itemCount: itemResults.length,
);
}
@override
Widget buildSuggestions(BuildContext context) {
// TODO - Implement
return Column();
}
// Ensure the search theme matches the app theme
@override
ThemeData appBarTheme(BuildContext context) {
final ThemeData theme = Theme.of(context);
return theme;
}
}