mirror of
https://github.com/inventree/inventree-app.git
synced 2025-04-28 13:36:50 +00:00
* Add action for scanning a stock location into another location * Adds barcode scan handler for new functionality * Handle scanning of stock location * Cleanup * Refactor existing barcode scanning functions - Will require extensive testing and validation * Add entry to release notes * Delete dead code * Improved ordering based on stock quantity * Bug fix for 'adjustStock' function * Improve error responses for barcode scanning * Improve error responses for barcode scanning * Remove old debug statements * Add some extra explanatory texts * Icon change * Fixes for unit tests * Adds extra functionality for user profile manager * Refactor barcode code - do not rely on BuildContext * Adds initial unit testing for barcode scanning - Work on mocking barcode data - Add hooks for testing snackBar and audio files * Linting fixes * More barcode unit tests * Cleanup unit tests for barcode * Remove unused import * Handle HTTPException in API * Improvements for API unit testing * Unit testing for scanning item into location * Add unit test for scanning in items from a location context * Unit test for scanning location into parent location * Improve feedback for barcode scanning events
500 lines
12 KiB
Dart
500 lines
12 KiB
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/barcode.dart";
|
|
import "package:inventree/inventree/stock.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:inventree/l10.dart";
|
|
import "package:inventree/widget/stock_list.dart";
|
|
|
|
|
|
class LocationDisplayWidget extends StatefulWidget {
|
|
|
|
LocationDisplayWidget(this.location, {Key? key}) : super(key: key);
|
|
|
|
final InvenTreeStockLocation? location;
|
|
|
|
final String title = L10().stockLocation;
|
|
|
|
@override
|
|
_LocationDisplayState createState() => _LocationDisplayState(location);
|
|
}
|
|
|
|
class _LocationDisplayState extends RefreshableState<LocationDisplayWidget> {
|
|
|
|
_LocationDisplayState(this.location);
|
|
|
|
final InvenTreeStockLocation? location;
|
|
|
|
@override
|
|
String getAppBarTitle(BuildContext context) { return L10().stockLocation; }
|
|
|
|
@override
|
|
List<Widget> getAppBarActions(BuildContext context) {
|
|
|
|
List<Widget> actions = [];
|
|
|
|
if (location != null) {
|
|
|
|
// Add "locate" button
|
|
if (InvenTreeAPI().supportsMixin("locate")) {
|
|
actions.add(
|
|
IconButton(
|
|
icon: FaIcon(FontAwesomeIcons.searchLocation),
|
|
tooltip: L10().locateLocation,
|
|
onPressed: () async {
|
|
_locateStockLocation(context);
|
|
},
|
|
)
|
|
);
|
|
}
|
|
|
|
// Add "edit" button
|
|
if (InvenTreeAPI().checkPermission("stock_location", "change")) {
|
|
actions.add(
|
|
IconButton(
|
|
icon: FaIcon(FontAwesomeIcons.edit),
|
|
tooltip: L10().edit,
|
|
onPressed: () { _editLocationDialog(context); },
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
return actions;
|
|
}
|
|
|
|
/*
|
|
* Request identification of this location
|
|
*/
|
|
Future<void> _locateStockLocation(BuildContext context) async {
|
|
|
|
final _loc = location;
|
|
|
|
if (_loc != null) {
|
|
InvenTreeAPI().locateItemOrLocation(context, location: _loc.pk);
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Launch a dialog form to edit this stock location
|
|
*/
|
|
void _editLocationDialog(BuildContext context) {
|
|
|
|
final _loc = location;
|
|
|
|
if (_loc == null) {
|
|
return;
|
|
}
|
|
|
|
_loc.editForm(
|
|
context,
|
|
L10().editLocation,
|
|
onSuccess: (data) async {
|
|
refresh(context);
|
|
showSnackIcon(L10().locationUpdated, success: true);
|
|
}
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
@override
|
|
Future<void> request(BuildContext context) async {
|
|
|
|
int pk = location?.pk ?? -1;
|
|
|
|
// Reload location information
|
|
if (location != null) {
|
|
final bool result = await location!.reload();
|
|
|
|
if (!result) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
|
|
// 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(() {});
|
|
}
|
|
|
|
Future<void> _newLocation(BuildContext context) async {
|
|
|
|
int pk = location?.pk ?? -1;
|
|
|
|
InvenTreeStockLocation().createForm(
|
|
context,
|
|
L10().locationCreate,
|
|
data: {
|
|
"parent": (pk > 0) ? pk : null,
|
|
},
|
|
onSuccess: (result) async {
|
|
|
|
Map<String, dynamic> data = result as Map<String, dynamic>;
|
|
|
|
if (data.containsKey("pk")) {
|
|
var loc = InvenTreeStockLocation.fromJson(data);
|
|
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => LocationDisplayWidget(loc)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
Future<void> _newStockItem(BuildContext context) async {
|
|
|
|
int pk = location?.pk ?? -1;
|
|
|
|
if (location != null && pk <= 0) {
|
|
return;
|
|
}
|
|
|
|
InvenTreeStockItem().createForm(
|
|
context,
|
|
L10().stockItemCreate,
|
|
data: {
|
|
"location": location != null ? pk : null,
|
|
},
|
|
onSuccess: (result) async {
|
|
|
|
Map<String, dynamic> data = result as Map<String, dynamic>;
|
|
|
|
if (data.containsKey("pk")) {
|
|
var item = InvenTreeStockItem.fromJson(data);
|
|
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => StockDetailWidget(item)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
);
|
|
|
|
}
|
|
|
|
Widget locationDescriptionCard({bool includeActions = true}) {
|
|
if (location == null) {
|
|
return Card(
|
|
child: ListTile(
|
|
title: Text(L10().stockTopLevel),
|
|
)
|
|
);
|
|
} else {
|
|
|
|
List<Widget> children = [
|
|
ListTile(
|
|
title: Text("${location!.name}"),
|
|
subtitle: Text("${location!.description}"),
|
|
trailing: Text("${location!.itemcount}"),
|
|
),
|
|
];
|
|
|
|
if (includeActions) {
|
|
children.add(
|
|
ListTile(
|
|
title: Text(L10().parentLocation),
|
|
subtitle: Text("${location!.parentPathString}"),
|
|
leading: FaIcon(FontAwesomeIcons.levelUpAlt, color: COLOR_CLICK),
|
|
onTap: () {
|
|
|
|
int parent = location?.parentId ?? -1;
|
|
|
|
if (parent < 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)));
|
|
}
|
|
});
|
|
}
|
|
},
|
|
)
|
|
);
|
|
}
|
|
|
|
return Card(
|
|
child: Column(
|
|
children: children,
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget getBottomNavBar(BuildContext context) {
|
|
return BottomNavigationBar(
|
|
currentIndex: tabIndex,
|
|
onTap: onTabSelectionChanged,
|
|
items: <BottomNavigationBarItem> [
|
|
BottomNavigationBarItem(
|
|
icon: FaIcon(FontAwesomeIcons.sitemap),
|
|
label: L10().details,
|
|
),
|
|
BottomNavigationBarItem(
|
|
icon: FaIcon(FontAwesomeIcons.boxes),
|
|
label: L10().stock,
|
|
),
|
|
BottomNavigationBarItem(
|
|
icon: FaIcon(FontAwesomeIcons.wrench),
|
|
label: L10().actions,
|
|
)
|
|
]
|
|
);
|
|
}
|
|
|
|
int stockItemCount = 0;
|
|
|
|
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(
|
|
children: detailTiles(),
|
|
);
|
|
case 1:
|
|
return PaginatedStockItemList(filters);
|
|
case 2:
|
|
return ListView(
|
|
children: ListTile.divideTiles(
|
|
context: context,
|
|
tiles: actionTiles()
|
|
).toList()
|
|
);
|
|
default:
|
|
return ListView();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget getBody(BuildContext context) {
|
|
return getSelectedWidget(tabIndex);
|
|
}
|
|
|
|
|
|
List<Widget> detailTiles() {
|
|
List<Widget> tiles = [
|
|
locationDescriptionCard(),
|
|
ListTile(
|
|
title: Text(
|
|
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)
|
|
)
|
|
));
|
|
}
|
|
|
|
return tiles;
|
|
}
|
|
|
|
|
|
List<Widget> actionTiles() {
|
|
List<Widget> tiles = [];
|
|
|
|
tiles.add(locationDescriptionCard(includeActions: false));
|
|
|
|
if (InvenTreeAPI().checkPermission("stock", "add")) {
|
|
|
|
tiles.add(
|
|
ListTile(
|
|
title: Text(L10().locationCreate),
|
|
subtitle: Text(L10().locationCreateDetail),
|
|
leading: FaIcon(FontAwesomeIcons.sitemap, color: COLOR_CLICK),
|
|
trailing: FaIcon(FontAwesomeIcons.plusCircle, color: COLOR_CLICK),
|
|
onTap: () async {
|
|
_newLocation(context);
|
|
},
|
|
)
|
|
);
|
|
|
|
tiles.add(
|
|
ListTile(
|
|
title: Text(L10().stockItemCreate),
|
|
subtitle: Text(L10().stockItemCreateDetail),
|
|
leading: FaIcon(FontAwesomeIcons.boxes, color: COLOR_CLICK),
|
|
trailing: FaIcon(FontAwesomeIcons.plusCircle, color: COLOR_CLICK),
|
|
onTap: () async {
|
|
_newStockItem(context);
|
|
},
|
|
)
|
|
);
|
|
|
|
}
|
|
|
|
if (location != null) {
|
|
|
|
// Scan stock item into location
|
|
if (InvenTreeAPI().checkPermission("stock", "change")) {
|
|
tiles.add(
|
|
ListTile(
|
|
title: Text(L10().barcodeScanItem),
|
|
subtitle: Text(L10().barcodeScanInItems),
|
|
leading: FaIcon(FontAwesomeIcons.exchangeAlt, color: COLOR_CLICK),
|
|
trailing: Icon(Icons.qr_code, color: COLOR_CLICK),
|
|
onTap: () {
|
|
|
|
var _loc = location;
|
|
|
|
if (_loc != null) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) =>
|
|
InvenTreeQRView(
|
|
StockLocationScanInItemsHandler(_loc)))
|
|
).then((value) {
|
|
refresh(context);
|
|
});
|
|
}
|
|
},
|
|
)
|
|
);
|
|
|
|
// Scan this location into another one
|
|
if (InvenTreeAPI().checkPermission("stock_location", "change")) {
|
|
tiles.add(
|
|
ListTile(
|
|
title: Text(L10().transferStockLocation),
|
|
subtitle: Text(L10().transferStockLocationDetail),
|
|
leading: FaIcon(FontAwesomeIcons.signInAlt, color: COLOR_CLICK),
|
|
trailing: Icon(Icons.qr_code, color: COLOR_CLICK),
|
|
onTap: () {
|
|
var _loc = location;
|
|
|
|
if (_loc != null) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(builder: (context) =>
|
|
InvenTreeQRView(
|
|
ScanParentLocationHandler(_loc)))
|
|
).then((value) {
|
|
refresh(context);
|
|
});
|
|
}
|
|
}
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (tiles.length <= 1) {
|
|
tiles.add(
|
|
ListTile(
|
|
title: Text(
|
|
L10().actionsNone,
|
|
style: TextStyle(
|
|
fontStyle: FontStyle.italic
|
|
),
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
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
|
|
);
|
|
}
|
|
}
|