import "dart:async"; import "package:async/async.dart"; 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/widget/stock_list.dart"; import "package:inventree/widget/category_list.dart"; import "package:inventree/widget/company_list.dart"; import "package:inventree/widget/location_list.dart"; // Widget for performing database-wide search class SearchWidget extends StatefulWidget { const SearchWidget(this.hasAppbar); final bool hasAppbar; @override _SearchDisplayState createState() => _SearchDisplayState(hasAppbar); } class _SearchDisplayState extends RefreshableState { _SearchDisplayState(this.hasAppBar) : super(); final _formKey = GlobalKey(); final bool hasAppBar; CancelableOperation? _search_query; @override void dispose() { super.dispose(); } @override String getAppBarTitle() => L10().search; @override AppBar? buildAppBar(BuildContext context, GlobalKey key) { if (hasAppBar) { return super.buildAppBar(context, key); } else { return null; } } final TextEditingController searchController = TextEditingController(); Timer? debounceTimer; /* * Decrement the number of pending / outstanding search queries */ void decrementPendingSearches() { if (nPendingSearches > 0) { nPendingSearches--; } } /* * Determine if the search is still running */ bool isSearching() { if (searchController.text.isEmpty) { return false; } return nPendingSearches > 0; } // Individual search result count (for legacy search API) int nPendingSearches = 0; 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(); } if (immediate) { search(text); } else { debounceTimer = Timer(Duration(milliseconds: 250), () { search(text); }); } } /* * Return the 'result count' for a particular query from the results map * e.g. * { * "part": { * "count": 102, * } * } */ int getSearchResultCount(Map results, String key) { dynamic result = results[key]; if (result == null || result is! Map) { return 0; } dynamic count = result["count"]; if (count == null || count is! int) { return 0; } return count; } // Actually perform the search query Future _perform_search(Map body) async { InvenTreeAPI().post( "search/", body: body, expectedStatusCode: 200).then((APIResponse response) { decrementPendingSearches(); Map results = {}; if (response.data is Map) { results = response.data as Map; } if (mounted) { setState(() { nPartResults = getSearchResultCount(results, "part"); nCategoryResults = getSearchResultCount(results, "partcategory"); nStockResults = getSearchResultCount(results, "stockitem"); nLocationResults = getSearchResultCount(results, "stocklocation"); nSupplierResults = 0; //getSearchResultCount(results, "") nPurchaseOrderResults = getSearchResultCount(results, "purchaseorder"); }); } }); } /* * Callback when the search input is changed */ Future search(String term) async { var api = InvenTreeAPI(); if (!mounted) { return; } setState(() { // Do not search on an empty string nPartResults = 0; nCategoryResults = 0; nStockResults = 0; nLocationResults = 0; nSupplierResults = 0; nPurchaseOrderResults = 0; nPendingSearches = 0; }); // Cancel the previous search query (if in progress) if (_search_query != null) { if (!_search_query!.isCanceled) { _search_query!.cancel(); } } _search_query = null; if (term.isEmpty) { return; } // Consolidated search allows us to perform *all* searches in a single query if (api.supportsConsolidatedSearch) { Map body = { "limit": 1, "search": term, }; // Part search if (InvenTreePart().canView) { body["part"] = {}; } // PartCategory search if (InvenTreePartCategory().canView) { body["partcategory"] = {}; } // StockItem search if (InvenTreeStockItem().canView) { body["stockitem"] = { "in_stock": true, }; } // StockLocation search if (InvenTreeStockLocation().canView) { body["stocklocation"] = {}; } // PurchaseOrder search if (InvenTreePurchaseOrder().canView) { body["purchaseorder"] = { "outstanding": true }; } if (body.isNotEmpty) { nPendingSearches++; _search_query = CancelableOperation.fromFuture( _perform_search(body), ); } } else { legacySearch(term); } } /* * Perform "legacy" search (without consolidated search API endpoint */ Future legacySearch(String term) async { // Search parts if (InvenTreePart().canView) { nPendingSearches++; InvenTreePart().count(searchQuery: term).then((int n) { if (term == searchController.text) { if (mounted) { decrementPendingSearches(); setState(() { nPartResults = n; }); } } }); } // Search part categories if (InvenTreePartCategory().canView) { nPendingSearches++; InvenTreePartCategory().count(searchQuery: term,).then((int n) { if (term == searchController.text) { if (mounted) { decrementPendingSearches(); setState(() { nCategoryResults = n; }); } } }); } // Search stock items if (InvenTreeStockItem().canView) { nPendingSearches++; InvenTreeStockItem().count(searchQuery: term).then((int n) { if (term == searchController.text) { if (mounted) { decrementPendingSearches(); setState(() { nStockResults = n; }); } } }); } // Search stock locations if (InvenTreeStockLocation().canView) { nPendingSearches++; InvenTreeStockLocation().count(searchQuery: term).then((int n) { if (term == searchController.text) { if (mounted) { decrementPendingSearches(); setState(() { nLocationResults = n; }); } } }); } // Search purchase orders if (InvenTreePurchaseOrder().canView) { nPendingSearches++; InvenTreePurchaseOrder().count( searchQuery: term, filters: { "outstanding": "true" } ).then((int n) { if (term == searchController.text) { if (mounted) { decrementPendingSearches(); setState(() { nPurchaseOrderResults = n; }); } } }); } } List _tiles(BuildContext context) { List tiles = []; // Search input tiles.add( ListTile( title: TextFormField( decoration: InputDecoration( hintText: L10().queryEmpty, ), key: _formKey, readOnly: false, autofocus: true, autocorrect: false, controller: searchController, onChanged: (String text) { onSearchTextChanged(text); }, onFieldSubmitted: (String text) { }, ), trailing: GestureDetector( child: FaIcon( searchController.text.isEmpty ? FontAwesomeIcons.magnifyingGlass : FontAwesomeIcons.deleteLeft, color: searchController.text.isEmpty ? COLOR_ACTION : COLOR_DANGER, ), onTap: () { searchController.clear(); onSearchTextChanged("", immediate: true); }, ), ) ); String query = searchController.text; List results = []; // Part Results if (nPartResults > 0) { results.add( ListTile( title: Text(L10().parts), leading: FaIcon(FontAwesomeIcons.shapes), trailing: Text("${nPartResults}"), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => PartList( { "original_search": query } ) ) ); } ) ); } // Part Category Results if (nCategoryResults > 0) { results.add( ListTile( title: Text(L10().partCategories), leading: FaIcon(FontAwesomeIcons.sitemap), trailing: Text("${nCategoryResults}"), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => PartCategoryList( { "original_search": query } ) ) ); }, ) ); } // Stock Item Results if (nStockResults > 0) { results.add( ListTile( title: Text(L10().stockItems), leading: FaIcon(FontAwesomeIcons.boxesStacked), trailing: Text("${nStockResults}"), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => StockItemList( { "original_search": query, } ) ) ); }, ) ); } // Stock location results if (nLocationResults > 0) { results.add( ListTile( title: Text(L10().stockLocations), leading: FaIcon(FontAwesomeIcons.locationDot), trailing: Text("${nLocationResults}"), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => StockLocationList( { "original_search": query } ) ) ); }, ) ); } // Suppliers if (nSupplierResults > 0) { results.add( ListTile( title: Text(L10().suppliers), leading: FaIcon(FontAwesomeIcons.building), trailing: Text("${nSupplierResults}"), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => CompanyListWidget( L10().suppliers, { "is_supplier": "true", "original_search": query } ) ) ); }, ) ); } // Purchase orders if (nPurchaseOrderResults > 0) { results.add( ListTile( title: Text(L10().purchaseOrders), leading: FaIcon(FontAwesomeIcons.cartShopping), trailing: Text("${nPurchaseOrderResults}"), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => PurchaseOrderListWidget( filters: { "original_search": query } ) ) ); }, ) ); } if (isSearching()) { tiles.add( ListTile( title: Text(L10().searching), leading: FaIcon(FontAwesomeIcons.magnifyingGlass), trailing: CircularProgressIndicator(), ) ); } if (!isSearching() && results.isEmpty && searchController.text.isNotEmpty) { tiles.add( ListTile( title: Text( L10().queryNoResults, style: TextStyle(fontStyle: FontStyle.italic), ), leading: FaIcon(FontAwesomeIcons.magnifyingGlassMinus), ) ); } else { for (Widget result in results) { tiles.add(result); } } return tiles; } @override Widget getBody(BuildContext context) { return Center( child: ListView( children: ListTile.divideTiles( context: context, tiles: _tiles(context), ).toList() ) ); } }