2
0
mirror of https://github.com/inventree/inventree-app.git synced 2025-05-14 13:03:11 +00:00

Display overlay screen for blocking operations (#186)

* Catch state error in homepage widget

* Add flutter_overlay_loader lib

- Displays an overlay screen to indicate blocking operation

* Wrap blocking widget transitions in a loading overlay

- Prevents user from doing other things while loading
- Shows the user that something is happening

* Linting fixes

* Show overlay when uploading attachment file

* Show overlay when downloading file also

* Show overlay when loading or submitting API forms

- Major improvements to usability "feel"

* UI improvements for stock item test results widget

* Fix API_FORM bug

- onSuccess function was not being called
This commit is contained in:
Oliver 2022-07-20 09:05:21 +10:00 committed by GitHub
parent 277193ecb0
commit 01dd046dd1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 150 additions and 183 deletions

View File

@ -651,12 +651,6 @@ class InvenTreeAPI {
*/
Future<void> downloadFile(String url, {bool openOnDownload = true}) async {
showSnackIcon(
L10().downloading,
icon: FontAwesomeIcons.download,
success: true
);
// Find the local downlods directory
final Directory dir = await getTemporaryDirectory();

View File

@ -18,6 +18,7 @@ import "package:inventree/widget/fields.dart";
import "package:inventree/l10.dart";
import "package:flutter/material.dart";
import "package:inventree/widget/progress.dart";
import "package:inventree/widget/snacks.dart";
@ -859,7 +860,9 @@ Future<void> launchApiForm(
if (url.isNotEmpty) {
showLoadingOverlay(context);
var options = await InvenTreeAPI().options(url);
hideLoadingOverlay();
// Invalid response from server
if (!options.isValid()) {
@ -902,7 +905,7 @@ Future<void> launchApiForm(
field.definition = extractFieldDefinition(serverFields, field.lookupPath);
// Skip fields with empty definitions
if (field.definition.isEmpty) {
if (url.isNotEmpty && field.definition.isEmpty) {
print("Warning: Empty field definition for field '${fieldName}'");
}
@ -987,8 +990,6 @@ class _APIFormWidgetState extends State<APIFormWidget> {
List<String> nonFieldErrors = [];
Function(Map<String, dynamic>)? onSuccess;
bool spacerRequired = false;
List<Widget> _buildForm() {
@ -1102,20 +1103,25 @@ class _APIFormWidgetState extends State<APIFormWidget> {
}
if (widget.method == "POST") {
showLoadingOverlay(context);
final response = await InvenTreeAPI().post(
widget.url,
body: data,
expectedStatusCode: null
);
hideLoadingOverlay();
return response;
} else {
showLoadingOverlay(context);
final response = await InvenTreeAPI().patch(
widget.url,
body: data,
expectedStatusCode: null
);
hideLoadingOverlay();
return response;
}
@ -1259,7 +1265,7 @@ class _APIFormWidgetState extends State<APIFormWidget> {
}
// Run custom onSuccess function
var successFunc = onSuccess;
var successFunc = widget.onSuccess;
// An "empty" URL means we don't want to submit the form anywhere
// Perhaps we just want to process the data?
@ -1398,4 +1404,4 @@ class _APIFormWidgetState extends State<APIFormWidget> {
);
}
}
}

View File

@ -708,9 +708,7 @@ class InvenTreeAttachment extends InvenTreeModel {
* Download this attachment file
*/
Future<void> downloadAttachment() async {
await InvenTreeAPI().downloadFile(attachment);
}
}

View File

@ -333,6 +333,12 @@
"filterTemplateDetail": "Show template parts",
"@filterTemplateDetail": {},
"filterTrackable": "Trackable",
"@filterTrackable": {},
"filterTrackableDetail": "Show trackable parts",
"@filterTrackableDetail": {},
"filterVirtual": "Virtual",
"@filterVirtual": {},

View File

@ -6,6 +6,7 @@ import "package:font_awesome_flutter/font_awesome_flutter.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/inventree/model.dart";
import "package:inventree/widget/fields.dart";
import "package:inventree/widget/progress.dart";
import "package:inventree/widget/snacks.dart";
import "package:inventree/widget/refreshable_state.dart";
import "package:inventree/l10.dart";
@ -51,8 +52,8 @@ class _AttachmentWidgetState extends RefreshableState<AttachmentWidget> {
icon: FaIcon(FontAwesomeIcons.plusCircle),
onPressed: () async {
FilePickerDialog.pickFile(
onPicked: (File file) {
upload(file);
onPicked: (File file) async {
await upload(context, file);
}
);
},
@ -63,9 +64,11 @@ class _AttachmentWidgetState extends RefreshableState<AttachmentWidget> {
return actions;
}
Future<void> upload(File file) async {
Future<void> upload(BuildContext context, File file) async {
showLoadingOverlay(context);
final bool result = await widget.attachment.uploadAttachment(file, widget.referenceId);
hideLoadingOverlay();
if (result) {
showSnackIcon(L10().uploadSuccess, success: true);
@ -121,7 +124,9 @@ class _AttachmentWidgetState extends RefreshableState<AttachmentWidget> {
subtitle: Text(attachment.comment),
leading: FaIcon(attachment.icon, color: COLOR_CLICK),
onTap: () async {
showLoadingOverlay(context);
await attachment.downloadAttachment();
hideLoadingOverlay();
},
));
}

View File

@ -12,6 +12,7 @@ import "package:inventree/inventree/part.dart";
import "package:inventree/widget/paginator.dart";
import "package:inventree/widget/part_detail.dart";
import "package:inventree/widget/progress.dart";
import "package:inventree/widget/refreshable_state.dart";
@ -125,11 +126,14 @@ class _PaginatedBomListState extends PaginatedSearchState<PaginatedBomList> {
height: 40,
),
onTap: subPart == null ? null : () async {
InvenTreePart().get(bomItem.subPartId).then((var part) {
if (part is InvenTreePart) {
Navigator.push(context, MaterialPageRoute(builder: (context) => PartDetailWidget(part)));
}
});
showLoadingOverlay(context);
var part = await InvenTreePart().get(bomItem.subPartId);
hideLoadingOverlay();
if (part is InvenTreePart) {
Navigator.push(context, MaterialPageRoute(builder: (context) => PartDetailWidget(part)));
}
},
);
}

View File

@ -9,6 +9,7 @@ import "package:inventree/inventree/part.dart";
import "package:inventree/widget/category_list.dart";
import "package:inventree/widget/part_list.dart";
import "package:inventree/widget/progress.dart";
import "package:inventree/widget/snacks.dart";
import "package:inventree/widget/part_detail.dart";
import "package:inventree/widget/refreshable_state.dart";
@ -125,16 +126,21 @@ class _CategoryDisplayState extends RefreshableState<CategoryDisplayWidget> {
FontAwesomeIcons.levelUpAlt,
color: COLOR_CLICK,
),
onTap: () {
if (category == null || ((category?.parentId ?? 0) < 0)) {
onTap: () async {
int parentId = category?.parentId ?? -1;
if (parentId < 0) {
Navigator.push(context, MaterialPageRoute(builder: (context) => CategoryDisplayWidget(null)));
} else {
// TODO - Refactor this code into the InvenTreePart class
InvenTreePartCategory().get(category?.parentId ?? -1).then((var cat) {
if (cat is InvenTreePartCategory) {
Navigator.push(context, MaterialPageRoute(builder: (context) => CategoryDisplayWidget(cat)));
}
});
showLoadingOverlay(context);
var cat = await InvenTreePartCategory().get(parentId);
hideLoadingOverlay();
if (cat is InvenTreePartCategory) {
Navigator.push(context, MaterialPageRoute(builder: (context) => CategoryDisplayWidget(cat)));
}
}
},
)

View File

@ -54,11 +54,13 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
});
InvenTreeAPI().registerCallback(() {
setState(() {
// Reload the widget
});
});
if (mounted) {
setState(() {
// Reload the widget
});
}
});
}
// Index of bottom navigation bar
@ -192,6 +194,11 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
return;
}
// Ignore if the widget is no longer active
if (!mounted) {
return;
}
final notifications = await InvenTreeNotification().list();
setState(() {

View File

@ -10,6 +10,7 @@ import "package:inventree/l10.dart";
import "package:inventree/inventree/stock.dart";
import "package:inventree/widget/location_list.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";
@ -218,19 +219,21 @@ class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> {
title: Text(L10().parentLocation),
subtitle: Text("${location!.parentPathString}"),
leading: FaIcon(FontAwesomeIcons.levelUpAlt, color: COLOR_CLICK),
onTap: () {
onTap: () async {
int parent = location?.parentId ?? -1;
int parentId = location?.parentId ?? -1;
if (parent < 0) {
if (parentId < 0) {
Navigator.push(context, MaterialPageRoute(builder: (context) => LocationDisplayWidget(null)));
} else {
InvenTreeStockLocation().get(parent).then((var loc) {
if (loc is InvenTreeStockLocation) {
Navigator.push(context, MaterialPageRoute(builder: (context) => LocationDisplayWidget(loc)));
}
});
showLoadingOverlay(context);
var loc = await InvenTreeStockLocation().get(parentId);
hideLoadingOverlay();
if (loc is InvenTreeStockLocation) {
Navigator.push(context, MaterialPageRoute(builder: (context) => LocationDisplayWidget(loc)));
}
}
},
)

View File

@ -384,8 +384,8 @@ abstract class PaginatedSearchState<T extends PaginatedSearchWidget> extends Sta
PagedSliverList.separated(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<InvenTreeModel>(
itemBuilder: (context, item, index) {
return buildItem(context, item);
itemBuilder: (ctx, item, index) {
return buildItem(ctx, item);
},
noItemsFoundIndicatorBuilder: (context) {
return NoResultsWidget(noResultsText);
@ -450,9 +450,11 @@ class NoResultsWidget extends StatelessWidget {
Widget build(BuildContext context) {
return ListTile(
title: Text(L10().noResults),
subtitle: Text(description),
leading: FaIcon(FontAwesomeIcons.exclamationCircle),
title: Text(
description,
style: TextStyle(fontStyle: FontStyle.italic),
),
leading: FaIcon(FontAwesomeIcons.exclamationCircle, color: COLOR_WARNING),
);
}

View File

@ -253,15 +253,17 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
title: Text(L10().partCategory),
subtitle: Text("${part.categoryName}"),
leading: FaIcon(FontAwesomeIcons.sitemap, color: COLOR_CLICK),
onTap: () {
onTap: () async {
if (part.categoryId > 0) {
InvenTreePartCategory().get(part.categoryId).then((var cat) {
if (cat is InvenTreePartCategory) {
Navigator.push(context, MaterialPageRoute(
builder: (context) => CategoryDisplayWidget(cat)));
}
});
showLoadingOverlay(context);
var cat = await InvenTreePartCategory().get(part.categoryId);
hideLoadingOverlay();
if (cat is InvenTreePartCategory) {
Navigator.push(context, MaterialPageRoute(
builder: (context) => CategoryDisplayWidget(cat)));
}
}
},
)

View File

@ -104,6 +104,10 @@ class _PaginatedPartListState extends PaginatedSearchState<PaginatedPartList> {
"label": L10().filterTemplate,
"help_text": L10().filterTemplateDetail
},
"trackable": {
"label": L10().filterTrackable,
"help_text": L10().filterTrackableDetail,
},
"virtual": {
"label": L10().filterVirtual,
"help_text": L10().filterVirtualDetail,
@ -122,15 +126,6 @@ class _PaginatedPartListState extends PaginatedSearchState<PaginatedPartList> {
return page;
}
void _openPart(BuildContext context, int pk) {
// Attempt to load the part information
InvenTreePart().get(pk).then((var part) {
if (part is InvenTreePart) {
Navigator.push(context, MaterialPageRoute(builder: (context) => PartDetailWidget(part)));
}
});
}
@override
Widget buildItem(BuildContext context, InvenTreeModel model) {
@ -147,7 +142,7 @@ class _PaginatedPartListState extends PaginatedSearchState<PaginatedPartList> {
height: 40,
),
onTap: () {
_openPart(context, part.pk);
Navigator.push(context, MaterialPageRoute(builder: (context) => PartDetailWidget(part)));
},
);
}

View File

@ -1,6 +1,7 @@
import "package:flutter/material.dart";
import "package:flutter_overlay_loader/flutter_overlay_loader.dart";
/*
* Construct a circular progress indicator
@ -10,4 +11,17 @@ Widget progressIndicator() {
return Center(
child: CircularProgressIndicator()
);
}
}
void showLoadingOverlay(BuildContext context) {
Loader.show(
context,
themeData: Theme.of(context).copyWith(colorScheme: ColorScheme.fromSwatch())
);
}
void hideLoadingOverlay() {
Loader.hide();
}

View File

@ -64,9 +64,12 @@ abstract class RefreshableState<T extends StatefulWidget> extends State<T> with
// Update current tab selection
void onTabSelectionChanged(int index) {
setState(() {
tabIndex = index;
});
if (mounted) {
setState(() {
tabIndex = index;
});
}
}
@override
@ -87,6 +90,10 @@ abstract class RefreshableState<T extends StatefulWidget> extends State<T> with
Future<void> refresh(BuildContext context) async {
if (!mounted) {
return;
}
setState(() {
loading = true;
});

View File

@ -1,91 +0,0 @@
import "package:inventree/inventree/part.dart";
import "package:inventree/widget/part_detail.dart";
import "package:inventree/widget/progress.dart";
import "package:inventree/widget/refreshable_state.dart";
import "package:flutter/material.dart";
import "package:inventree/l10.dart";
import "package:inventree/api.dart";
class StarredPartWidget extends StatefulWidget {
const StarredPartWidget({Key? key}) : super(key: key);
@override
_StarredPartState createState() => _StarredPartState();
}
class _StarredPartState extends RefreshableState<StarredPartWidget> {
List<InvenTreePart> starredParts = [];
@override
String getAppBarTitle(BuildContext context) => L10().partsStarred;
@override
Future<void> request(BuildContext context) async {
final parts = await InvenTreePart().list(filters: {"starred": "true"});
starredParts.clear();
for (int idx = 0; idx < parts.length; idx++) {
if (parts[idx] is InvenTreePart) {
starredParts.add(parts[idx] as InvenTreePart);
}
}
}
Widget _partResult(BuildContext context, int index) {
final part = starredParts[index];
return ListTile(
title: Text(part.fullname),
subtitle: Text(part.description),
leading: InvenTreeAPI().getImage(
part.thumbnail,
width: 40,
height: 40
),
onTap: () {
InvenTreePart().get(part.pk).then((var prt) {
if (prt is InvenTreePart) {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => PartDetailWidget(prt))
);
}
});
}
);
}
@override
Widget getBody(BuildContext context) {
if (loading) {
return progressIndicator();
}
if (starredParts.isEmpty) {
return ListView(
children: [
ListTile(
title: Text(L10().partsNone),
subtitle: Text(L10().partsStarredNone)
)
],
);
}
return ListView.separated(
itemCount: starredParts.length,
itemBuilder: _partResult,
separatorBuilder: (_, __) => const Divider(height: 3),
physics: ClampingScrollPhysics(),
);
}
}

View File

@ -475,13 +475,16 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
color: item.statusColor
)
),
onTap: () {
onTap: () async {
if (item.partId > 0) {
InvenTreePart().get(item.partId).then((var part) {
if (part is InvenTreePart) {
Navigator.push(context, MaterialPageRoute(builder: (context) => PartDetailWidget(part)));
}
});
showLoadingOverlay(context);
var part = await InvenTreePart().get(item.partId);
hideLoadingOverlay();
if (part is InvenTreePart) {
Navigator.push(context, MaterialPageRoute(builder: (context) => PartDetailWidget(part)));
}
}
},
//trailing: Text(item.serialOrQuantityDisplay()),
@ -533,15 +536,17 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
FontAwesomeIcons.mapMarkerAlt,
color: COLOR_CLICK,
),
onTap: () {
onTap: () async {
if (item.locationId > 0) {
InvenTreeStockLocation().get(item.locationId).then((var loc) {
if (loc is InvenTreeStockLocation) {
Navigator.push(context, MaterialPageRoute(
builder: (context) => LocationDisplayWidget(loc)));
}
});
showLoadingOverlay(context);
var loc = await InvenTreeStockLocation().get(item.locationId);
hideLoadingOverlay();
if (loc is InvenTreeStockLocation) {
Navigator.push(context, MaterialPageRoute(
builder: (context) => LocationDisplayWidget(loc)));
}
}
},
),

View File

@ -156,6 +156,7 @@ class _StockItemTestResultDisplayState extends RefreshableState<StockItemTestRes
String _test = "";
bool _result = false;
String _value = "";
String _notes = "";
FaIcon _icon = FaIcon(FontAwesomeIcons.questionCircle, color: COLOR_BLUE);
bool _valueRequired = false;
@ -168,11 +169,13 @@ class _StockItemTestResultDisplayState extends RefreshableState<StockItemTestRes
_value = item.latestResult()?.value ?? "";
_valueRequired = item.requiresValue;
_attachmentRequired = item.requiresAttachment;
_notes = item.latestResult()?.notes ?? "";
} else if (item is InvenTreeStockItemTestResult) {
_result = item.result;
_test = item.testName;
_required = false;
_value = item.value;
_notes = item.notes;
}
if (_result == true) {
@ -187,8 +190,9 @@ class _StockItemTestResultDisplayState extends RefreshableState<StockItemTestRes
tiles.add(ListTile(
title: Text(_test, style: TextStyle(fontWeight: _required ? FontWeight.bold : FontWeight.normal)),
subtitle: Text(_value),
trailing: _icon,
subtitle: Text(_notes),
trailing: Text(_value),
leading: _icon,
onLongPress: () {
addTestResult(
context,

View File

@ -104,14 +104,6 @@ class _PaginatedStockItemListState extends PaginatedSearchState<PaginatedStockIt
return page;
}
void _openItem(BuildContext context, int pk) {
InvenTreeStockItem().get(pk).then((var item) {
if (item is InvenTreeStockItem) {
Navigator.push(context, MaterialPageRoute(builder: (context) => StockDetailWidget(item)));
}
});
}
@override
Widget buildItem(BuildContext context, InvenTreeModel model) {
@ -132,7 +124,7 @@ class _PaginatedStockItemListState extends PaginatedSearchState<PaginatedStockIt
),
),
onTap: () {
_openItem(context, item.pk);
Navigator.push(context, MaterialPageRoute(builder: (context) => StockDetailWidget(item)));
},
);
}

View File

@ -291,6 +291,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.9+1"
flutter_overlay_loader:
dependency: "direct main"
description:
name: flutter_overlay_loader
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:

View File

@ -22,12 +22,13 @@ dependencies:
flutter_localizations:
sdk: flutter
flutter_markdown: ^0.6.9 # Rendering markdown
flutter_overlay_loader: ^2.0.0 # Overlay screen support
font_awesome_flutter: ^9.1.0 # FontAwesome icon set
http: ^0.13.4
image_picker: ^0.8.3 # Select or take photos
infinite_scroll_pagination: ^3.1.0 # Let the server do all the work!
intl: ^0.17.0
one_context: ^1.1.0 # Dialogs without requiring context
one_context: ^1.1.1 # Dialogs without requiring context
open_file: ^3.2.1 # Open local files
package_info_plus: ^1.0.4 # App information introspection
path: ^1.8.0