mirror of
https://github.com/inventree/inventree-app.git
synced 2025-04-27 21:16:48 +00:00
* Define classes for extra line item * Display PO extra line items - Also, some refactoring * Support extra line items for sales order * linting fixes * Update release notes
545 lines
14 KiB
Dart
545 lines
14 KiB
Dart
import "package:flutter/material.dart";
|
|
|
|
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
|
|
import "package:infinite_scroll_pagination/infinite_scroll_pagination.dart";
|
|
|
|
import "package:inventree/api_form.dart";
|
|
import "package:inventree/app_colors.dart";
|
|
import "package:inventree/l10.dart";
|
|
|
|
import "package:inventree/inventree/model.dart";
|
|
import "package:inventree/inventree/sentry.dart";
|
|
import "package:inventree/preferences.dart";
|
|
|
|
import "package:inventree/widget/refreshable_state.dart";
|
|
|
|
|
|
/*
|
|
* Abstract base widget class for rendering a PaginatedSearchState
|
|
*/
|
|
abstract class PaginatedSearchWidget extends StatefulWidget {
|
|
|
|
const PaginatedSearchWidget({this.filters = const {}, this.title = ""});
|
|
|
|
final String title;
|
|
|
|
String get searchTitle => title;
|
|
|
|
final Map<String, String> filters;
|
|
}
|
|
|
|
|
|
/*
|
|
* 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;
|
|
|
|
bool showSearchWidget = false;
|
|
|
|
// Prefix for storing and loading pagination options
|
|
// Override in implementing class
|
|
String get prefix => "prefix_";
|
|
|
|
// Should be overridden by an implementing subclass
|
|
Map<String, Map<String, dynamic>> get filterOptions => {};
|
|
|
|
// Return the boolean value of a particular boolean filter
|
|
Future<dynamic> getFilterValue(String key) async {
|
|
key = "${prefix}filter_${key}";
|
|
|
|
Map<String, dynamic> opts = filterOptions[key] ?? {};
|
|
|
|
bool tristate = (opts["tristate"] ?? true) as bool;
|
|
dynamic backup = tristate ? null : opts["default"];
|
|
final result = await InvenTreeSettingsManager().getValue(key, backup);
|
|
|
|
return result;
|
|
}
|
|
|
|
// Set the boolean value of a particular boolean filter
|
|
Future<void> setFilterValue(String key, dynamic value) async {
|
|
key = "${prefix}filter_${key}";
|
|
|
|
if (value == null) {
|
|
await InvenTreeSettingsManager().removeValue(key);
|
|
} else {
|
|
await InvenTreeSettingsManager().setValue(key, value);
|
|
}
|
|
}
|
|
|
|
// Construct the boolean filter options for this list
|
|
Future<Map<String, String>> constructFilters() async {
|
|
|
|
Map<String, String> f = {};
|
|
|
|
for (String k in filterOptions.keys) {
|
|
dynamic value = await getFilterValue(k);
|
|
|
|
// Skip null values
|
|
if (value == null) {
|
|
continue;
|
|
}
|
|
f[k] = value.toString();
|
|
}
|
|
|
|
return f;
|
|
}
|
|
|
|
// Return a map of sorting options available for this list
|
|
// Should be overridden by an implementing subclass
|
|
Map<String, String> get orderingOptions => {};
|
|
|
|
// Return the selected ordering "field" for this list widget
|
|
Future<String> orderingField() async {
|
|
dynamic field = await InvenTreeSettingsManager().getValue("${prefix}ordering_field", null);
|
|
|
|
if (field != null && orderingOptions.containsKey(field.toString())) {
|
|
// A valid ordering field has been found
|
|
return field.toString();
|
|
} else if (orderingOptions.isNotEmpty) {
|
|
// By default, return the first specified key
|
|
return orderingOptions.keys.first;
|
|
} else {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// Return the selected ordering "order" ("+" or "-") for this list widget
|
|
Future<String> orderingOrder() async {
|
|
dynamic order = await InvenTreeSettingsManager().getValue("${prefix}ordering_order", "+");
|
|
|
|
return order == "+" ? "+" : "-";
|
|
}
|
|
|
|
// Return string for determining 'ordering' of paginated list
|
|
Future<String> get orderingString async {
|
|
dynamic field = await orderingField();
|
|
dynamic order = await orderingOrder();
|
|
|
|
// Return an empty string if no field is provided
|
|
if (field.toString().isEmpty) {
|
|
return "";
|
|
}
|
|
|
|
return "${order}${field}";
|
|
}
|
|
|
|
// Update the (configurable) filters for this paginated list
|
|
Future<void> _setOrderingOptions(BuildContext context) async {
|
|
// Retrieve stored setting
|
|
dynamic _field = await orderingField();
|
|
dynamic _order = await orderingOrder();
|
|
|
|
// Construct the 'ordering' options
|
|
List<Map<String, dynamic>> _opts = [];
|
|
|
|
orderingOptions.forEach((k, v) => _opts.add({
|
|
"value": k.toString(),
|
|
"display_name": v.toString()
|
|
}));
|
|
|
|
if (_field == null && _opts.isNotEmpty) {
|
|
_field = _opts.first["value"];
|
|
}
|
|
|
|
Map<String, dynamic> fields = {
|
|
"ordering_field": {
|
|
"type": "choice",
|
|
"label": "Ordering Field",
|
|
"required": true,
|
|
"choices": _opts,
|
|
"value": _field,
|
|
},
|
|
"ordering_order": {
|
|
"type": "choice",
|
|
"label": "Ordering Direction",
|
|
"required": true,
|
|
"value": _order,
|
|
"choices": [
|
|
{
|
|
"value": "+",
|
|
"display_name": "Ascending",
|
|
},
|
|
{
|
|
"value": "-",
|
|
"display_name": "Descending",
|
|
}
|
|
]
|
|
}
|
|
};
|
|
|
|
// Add in selected 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?;
|
|
|
|
List<dynamic> choices = (opts["choices"] ?? []) as List<dynamic>;
|
|
|
|
bool tristate = (opts["tristate"] ?? true) as bool;
|
|
|
|
dynamic v = await getFilterValue(key);
|
|
|
|
// Prevent null value if not tristate
|
|
if (!tristate && v == null) {
|
|
v = false;
|
|
}
|
|
|
|
Map<String, dynamic> filter = {
|
|
"type": "boolean",
|
|
"display_name": label,
|
|
"label": label,
|
|
"help_text": help_text,
|
|
"value": v,
|
|
"tristate": (opts["tristate"] ?? true) as bool,
|
|
};
|
|
|
|
if (choices.isNotEmpty) {
|
|
// Configure as a choice input
|
|
filter["type"] = "choice";
|
|
filter["choices"] = choices;
|
|
|
|
filter.remove("tristate");
|
|
}
|
|
|
|
fields[key] = filter;
|
|
}
|
|
|
|
// Launch an interactive form for the user to select options
|
|
launchApiForm(
|
|
context,
|
|
L10().filteringOptions,
|
|
"",
|
|
fields,
|
|
icon: TablerIcons.circle_check,
|
|
onSuccess: (Map<String, dynamic> data) async {
|
|
|
|
// Extract data from the processed form
|
|
String f = (data["ordering_field"] ?? _field) as String;
|
|
String o = (data["ordering_order"] ?? _order) as String;
|
|
|
|
// Save values to settings
|
|
await InvenTreeSettingsManager().setValue("${prefix}ordering_field", f);
|
|
await InvenTreeSettingsManager().setValue("${prefix}ordering_order", o);
|
|
|
|
// Save boolean fields
|
|
for (String key in filterOptions.keys) {
|
|
await setFilterValue(key, data[key]);
|
|
}
|
|
|
|
// Refresh data from the server
|
|
_pagingController.refresh();
|
|
}
|
|
);
|
|
}
|
|
|
|
// Search query term
|
|
String searchTerm = "";
|
|
|
|
int resultCount = 0;
|
|
|
|
String resultsString() {
|
|
|
|
if (resultCount <= 0) {
|
|
return noResultsText;
|
|
} else {
|
|
return "${resultCount} ${L10().results}";
|
|
}
|
|
}
|
|
|
|
// Text controller
|
|
final TextEditingController searchController = TextEditingController();
|
|
|
|
// Pagination controller
|
|
final PagingController<int, InvenTreeModel> _pagingController = PagingController(firstPageKey: 0);
|
|
|
|
void refresh() {
|
|
_pagingController.refresh();
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
_pagingController.addPageRequestListener((pageKey) {
|
|
_fetchPage(pageKey);
|
|
});
|
|
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pagingController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/*
|
|
* Custom function to request a single page of results from the server.
|
|
* Each implementing class must override this function,
|
|
* and return an InvenTreePageResponse object with the correct data format
|
|
*/
|
|
Future<InvenTreePageResponse?> requestPage(int limit, int offset, Map<String, String> params) async {
|
|
|
|
// Default implementation returns null - must be overridden
|
|
return null;
|
|
}
|
|
|
|
/*
|
|
* Request a single page of results from the server
|
|
*/
|
|
Future<void> _fetchPage(int pageKey) async {
|
|
try {
|
|
Map<String, String> params = widget.filters;
|
|
|
|
// Include user search term
|
|
if (searchTerm.isNotEmpty) {
|
|
|
|
String _search = searchTerm;
|
|
|
|
// Include original search in search test
|
|
String original = params["original_search"] ?? "";
|
|
|
|
if (original.isNotEmpty) {
|
|
_search = "${original} ${_search}";
|
|
}
|
|
|
|
params["search"] = "${_search}";
|
|
}
|
|
|
|
// Use custom query ordering if available
|
|
String o = await orderingString;
|
|
if (o.isNotEmpty) {
|
|
params["ordering"] = o;
|
|
}
|
|
|
|
Map<String, String> f = await constructFilters();
|
|
|
|
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;
|
|
|
|
final isLastPage = pageLength < _pageSize;
|
|
|
|
List<InvenTreeModel> items = [];
|
|
|
|
if (page != null) {
|
|
for (var result in page.results) {
|
|
items.add(result);
|
|
}
|
|
}
|
|
|
|
if (isLastPage) {
|
|
_pagingController.appendLastPage(items);
|
|
} else {
|
|
final int nextPageKey = pageKey + pageLength;
|
|
_pagingController.appendPage(items, nextPageKey);
|
|
}
|
|
|
|
setState(() {
|
|
resultCount = pageCount;
|
|
});
|
|
} catch (error, stackTrace) {
|
|
_pagingController.error = error;
|
|
|
|
sentryReportError(
|
|
"paginator.fetchPage",
|
|
error, stackTrace,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Callback function when the search term is updated
|
|
void updateSearchTerm() {
|
|
searchTerm = searchController.text;
|
|
_pagingController.refresh();
|
|
|
|
if (mounted) {
|
|
setState(() {});
|
|
}
|
|
}
|
|
|
|
// Function to construct a single paginated item
|
|
// Must be overridden in an implementing subclass
|
|
Widget buildItem(BuildContext context, InvenTreeModel item) {
|
|
|
|
// This method must be overridden by the child class
|
|
return ListTile(
|
|
title: Text("*** UNIMPLEMENTED ***"),
|
|
subtitle: Text("*** buildItem() is unimplemented for this widget!"),
|
|
);
|
|
}
|
|
|
|
// Return a string which is displayed when there are no results
|
|
// Can be overridden by an implementing subclass
|
|
String get noResultsText => L10().noResults;
|
|
|
|
@override
|
|
Widget build (BuildContext context) {
|
|
|
|
List<Widget> children = [
|
|
buildTitleWidget(context),
|
|
Divider(),
|
|
];
|
|
|
|
if (showSearchWidget) {
|
|
children.add(buildSearchInput(context));
|
|
}
|
|
|
|
children.add(
|
|
Expanded(
|
|
child: CustomScrollView(
|
|
shrinkWrap: true,
|
|
physics: AlwaysScrollableScrollPhysics(),
|
|
scrollDirection: Axis.vertical,
|
|
slivers: <Widget>[
|
|
PagedSliverList.separated(
|
|
pagingController: _pagingController,
|
|
builderDelegate: PagedChildBuilderDelegate<InvenTreeModel>(
|
|
itemBuilder: (ctx, item, index) {
|
|
return buildItem(ctx, item);
|
|
},
|
|
noItemsFoundIndicatorBuilder: (context) {
|
|
return NoResultsWidget(noResultsText);
|
|
}
|
|
),
|
|
separatorBuilder: (context, item) => const Divider(height: 1),
|
|
)
|
|
]
|
|
)
|
|
)
|
|
);
|
|
|
|
return RefreshIndicator(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
children: children,
|
|
),
|
|
onRefresh: () async {
|
|
_pagingController.refresh();
|
|
},
|
|
);
|
|
}
|
|
|
|
/*
|
|
* Build the title widget for this list
|
|
*/
|
|
Widget buildTitleWidget(BuildContext context) {
|
|
|
|
const double icon_size = 32;
|
|
|
|
List<Widget> _icons = [];
|
|
|
|
if (filterOptions.isNotEmpty || orderingOptions.isNotEmpty) {
|
|
_icons.add(IconButton(
|
|
onPressed: () async {
|
|
_setOrderingOptions(context);
|
|
},
|
|
icon: Icon(Icons.filter_alt, size: icon_size)
|
|
));
|
|
}
|
|
|
|
_icons.add(IconButton(
|
|
onPressed: () {
|
|
setState(() {
|
|
showSearchWidget = !showSearchWidget;
|
|
});
|
|
},
|
|
icon: Icon(showSearchWidget ? Icons.zoom_out : Icons.search, size: icon_size)
|
|
));
|
|
|
|
_icons.add(IconButton(
|
|
onPressed: () async {
|
|
updateSearchTerm();
|
|
},
|
|
icon: Icon(Icons.refresh, size: icon_size),
|
|
));
|
|
|
|
return ListTile(
|
|
title: Text(
|
|
widget.searchTitle,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
subtitle: Text(
|
|
"${L10().results}: ${resultCount}",
|
|
style: TextStyle(
|
|
fontStyle: FontStyle.italic
|
|
),
|
|
),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: _icons,
|
|
),
|
|
);
|
|
}
|
|
|
|
/*
|
|
* Construct a search input text field for the user to enter a search term
|
|
*/
|
|
Widget buildSearchInput(BuildContext context) {
|
|
return ListTile(
|
|
trailing: GestureDetector(
|
|
child: Icon(
|
|
searchController.text.isEmpty ? TablerIcons.search : TablerIcons.backspace,
|
|
color: searchController.text.isNotEmpty ? COLOR_DANGER : COLOR_ACTION,
|
|
),
|
|
onTap: () {
|
|
if (searchController.text.isNotEmpty) {
|
|
searchController.clear();
|
|
}
|
|
updateSearchTerm();
|
|
},
|
|
),
|
|
title: TextFormField(
|
|
controller: searchController,
|
|
onChanged: (value) {
|
|
updateSearchTerm();
|
|
},
|
|
decoration: InputDecoration(
|
|
hintText: L10().search,
|
|
),
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
class NoResultsWidget extends StatelessWidget {
|
|
|
|
const NoResultsWidget(this.description);
|
|
|
|
final String description;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
|
|
return ListTile(
|
|
title: Text(
|
|
description,
|
|
style: TextStyle(fontStyle: FontStyle.italic),
|
|
),
|
|
leading: Icon(TablerIcons.exclamation_circle, color: COLOR_WARNING),
|
|
);
|
|
}
|
|
|
|
}
|