2
0
mirror of https://github.com/inventree/inventree-app.git synced 2025-05-05 00:38:54 +00:00

Merge branch 'stock-actions'

This commit is contained in:
Oliver Walters 2020-04-10 00:53:39 +10:00
commit 14617686b2
10 changed files with 482 additions and 36 deletions

View File

@ -59,7 +59,7 @@ class InvenTreeAPI {
String makeApiUrl(String endpoint) {
return apiUrl + endpoint;
return _makeUrl("/api/" + endpoint);
}
String makeUrl(String endpoint) {
@ -272,20 +272,18 @@ class InvenTreeAPI {
}
// Perform a POST request
Future<http.Response> post(String url, {Map<String, String> body}) async {
Future<http.Response> post(String url, {Map<String, dynamic> body}) async {
var _url = makeApiUrl(url);
var _headers = defaultHeaders();
var _body = Map<String, String>();
var _headers = jsonHeaders();
// Copy across provided data
body.forEach((K, V) => _body[K] = V);
print("POST: ${_url} -> ${body.toString()}");
print("POST: " + _url);
var data = jsonEncode(body);
return http.post(_url,
headers: _headers,
body: _body,
body: data,
);
}
@ -324,6 +322,13 @@ class InvenTreeAPI {
return headers;
}
Map<String, String> jsonHeaders() {
var headers = defaultHeaders();
headers['Content-Type'] = 'application/json';
return headers;
}
String _authorizationHeader () {
if (_token.isNotEmpty) {
return "Token $_token";

View File

@ -56,7 +56,8 @@ class InvenTreeModel {
return obj;
}
String get url{ return path.join(URL, pk.toString()); }
// Return the API detail endpoint for this Model object
String get url => "${URL}/${pk}/";
/*
// Search this Model type in the database
@ -79,6 +80,25 @@ class InvenTreeModel {
// A map of "default" headers to use when performing a GET request
Map<String, String> defaultGetFilters() { return Map<String, String>(); }
/*
* Reload this object, by requesting data from the server
*/
Future<bool> reload() async {
var response = await api.get(url, params: defaultGetFilters());
if (response.statusCode != 200) {
print("Error retrieving data");
return false;
}
final Map<String, dynamic> data = json.decode(response.body);
jsondata = data;
return true;
}
// Return the detail view for the associated pk
Future<InvenTreeModel> get(int pk, {Map<String, String> filters}) async {
@ -102,7 +122,7 @@ class InvenTreeModel {
print("GET: $addr ${params.toString()}");
var response = await InvenTreeAPI().get(addr, params: params);
var response = await api.get(addr, params: params);
if (response.statusCode != 200) {
print("Error retrieving data");
@ -134,7 +154,7 @@ class InvenTreeModel {
// TODO - Add "timeout"
// TODO - Add error catching
var response = await InvenTreeAPI().get(URL, params:params);
var response = await api.get(URL, params:params);
// A list of "InvenTreeModel" items
List<InvenTreeModel> results = new List<InvenTreeModel>();

View File

@ -1,5 +1,6 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'model.dart';
import 'package:InvenTree/api.dart';
@ -121,6 +122,8 @@ class InvenTreeStockItem extends InvenTreeModel {
int get locationId => jsondata['location'] as int ?? -1;
bool isSerialized() => serialNumber != null && quantity.toInt() == 1;
String get locationName {
String loc = '';
@ -153,6 +156,54 @@ class InvenTreeStockItem extends InvenTreeModel {
return item;
}
Future<http.Response> countStock(double quan, {String notes}) async {
// Cannot 'count' a serialized StockItem
if (isSerialized()) {
return null;
}
// Cannot count negative stock
if (quan < 0) {
return null;
}
return api.post("/stock/count/", body: {
"item": {
"pk": "${pk}",
"quantity": "${quan}",
},
"notes": notes ?? '',
});
}
Future<http.Response> addStock(double quan, {String notes}) async {
if (isSerialized() || quan <= 0) return null;
return api.post("/stock/add/", body: {
"item": {
"pk": "${pk}",
"quantity": "${quan}",
},
"notes": notes ?? '',
});
}
Future<http.Response> removeStock(double quan, {String notes}) async {
if (isSerialized() || quan <= 0) return null;
return api.post("/stock/remove/", body: {
"item": {
"pk": "${pk}",
"quantity": "${quan}",
},
"notes": notes ?? '',
});
}
}
@ -194,5 +245,4 @@ class InvenTreeStockLocation extends InvenTreeModel {
return loc;
}
}

View File

@ -4,6 +4,7 @@ import 'package:InvenTree/inventree/stock.dart';
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/search.dart';
import 'package:InvenTree/widget/drawer.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
@ -192,7 +193,8 @@ class _MyHomePageState extends State<MyHomePage> {
void _search() {
if (!InvenTreeAPI().checkConnection(context)) return;
// TODO
Navigator.push(context, MaterialPageRoute(builder: (context) => SearchWidget()));
}
void _scan() {
@ -251,7 +253,7 @@ class _MyHomePageState extends State<MyHomePage> {
IconButton(
icon: FaIcon(FontAwesomeIcons.search),
tooltip: 'Search',
onPressed: null,
onPressed: _search,
),
],
),
@ -271,7 +273,7 @@ class _MyHomePageState extends State<MyHomePage> {
IconButton(
icon: new FaIcon(FontAwesomeIcons.search),
tooltip: 'Search',
onPressed: _unsupported,
onPressed: _search,
),
Text("Search"),
],

View File

@ -1,6 +1,7 @@
import 'package:InvenTree/api.dart';
import 'package:InvenTree/barcode.dart';
import 'package:InvenTree/widget/company_list.dart';
import 'package:InvenTree/widget/search.dart';
import 'package:flutter/material.dart';
import 'package:InvenTree/api.dart';
@ -32,6 +33,11 @@ class InvenTreeDrawer extends StatelessWidget {
Navigator.pushNamedAndRemoveUntil(context, "/", (r) => false);
}
void _search() {
_closeDrawer();
Navigator.push(context, MaterialPageRoute(builder: (context) => SearchWidget()));
}
/*
* Launch the camera to scan a QR code.
* Upon successful scan, data are passed off to be decoded.
@ -102,7 +108,7 @@ class InvenTreeDrawer extends StatelessWidget {
new ListTile(
title: new Text("Search"),
leading: new FaIcon(FontAwesomeIcons.search),
onTap: null,
onTap: _search,
),
new ListTile(
title: new Text("Scan Barcode"),

View File

@ -160,6 +160,11 @@ class _PartDisplayState extends State<PartDetailWidget> {
title: Text("Part Details"),
),
drawer: new InvenTreeDrawer(context),
floatingActionButton: FloatingActionButton(
child: FaIcon(FontAwesomeIcons.ellipsisH),
// TODO - Add pop-up icons
// Ref: https://stackoverflow.com/questions/46480221/flutter-floating-action-button-with-speed-dial#46480722
),
body: Center(
child: ListView(
children: partTiles(),

33
lib/widget/search.dart Normal file
View File

@ -0,0 +1,33 @@
import 'package:InvenTree/widget/drawer.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
class SearchWidget extends StatefulWidget {
@override
_SearchState createState() => _SearchState();
}
class _SearchState extends State<SearchWidget> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Search"),
),
drawer: new InvenTreeDrawer(context),
body: Center(
child: ListView(
children: <Widget>[
],
)
)
);
}
}

View File

@ -10,7 +10,9 @@ import 'package:flutter/material.dart';
import 'package:InvenTree/api.dart';
import 'package:InvenTree/widget/drawer.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
class StockDetailWidget extends StatefulWidget {
@ -25,12 +27,270 @@ class StockDetailWidget extends StatefulWidget {
class _StockItemDisplayState extends State<StockDetailWidget> {
final TextEditingController _quantityController = TextEditingController();
final TextEditingController _notesController = TextEditingController();
final _addStockKey = GlobalKey<FormState>();
final _removeStockKey = GlobalKey<FormState>();
final _countStockKey = GlobalKey<FormState>();
final _moveStockKey = GlobalKey<FormState>();
final _editStockKey = GlobalKey<FormState>();
_StockItemDisplayState(this.item) {
// TODO
}
final InvenTreeStockItem item;
/**
* Function to reload the page data
*/
Future<void> _refresh() async {
await item.reload();
setState(() {});
}
void _editStockItem() {
// TODO - Form for editing stock item
}
void _editStockItemDialog() {
return;
// TODO - Finish implementing this
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text("Edit Stock Item"),
actions: <Widget>[
FlatButton(
child: Text("Save"),
onPressed: () {
if (_editStockKey.currentState.validate()) {
// TODO
}
},
)
],
);
}
);
}
void _addStock() async {
Navigator.of(context).pop();
double quantity = double.parse(_quantityController.text);
_quantityController.clear();
// Await response to prevent the button from being pressed multiple times
var response = await item.addStock(quantity, notes: _notesController.text);
_notesController.clear();
// TODO - Handle error cases
_refresh();
}
void _addStockDialog() async {
showDialog(context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text("Add Stock"),
actions: <Widget>[
FlatButton(
child: Text("Add"),
onPressed: () {
if (_addStockKey.currentState.validate()) _addStock();
},
)
],
content: Form(
key: _addStockKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text("Current Quantity: ${item.quantity}"),
TextFormField(
decoration: InputDecoration(
labelText: "Add stock",
),
keyboardType: TextInputType.numberWithOptions(signed: false, decimal: true),
controller: _quantityController,
validator: (value) {
if (value.isEmpty) return "Value cannot be empty";
double quantity = double.tryParse(value);
if (quantity == null) return "Value cannot be converted to a number";
if (quantity <= 0) return "Value must be positive";
return null;
},
),
TextFormField(
decoration: InputDecoration(
labelText: "Notes",
),
controller: _notesController,
)
],
)
),
);
}
);
// TODO - Form for adding stock
}
void _removeStock() async {
Navigator.of(context).pop();
double quantity = double.parse(_quantityController.text);
_quantityController.clear();
var response = await item.removeStock(quantity, notes: _notesController.text);
_notesController.clear();
// TODO - Handle error cases
_refresh();
}
void _removeStockDialog() {
showDialog(context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text("Remove Stock"),
actions: <Widget>[
FlatButton(
child: Text("Remove"),
onPressed: () {
if (_removeStockKey.currentState.validate()) _removeStock();
},
)
],
content: Form(
key: _removeStockKey,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text("Current quantity: ${item.quantity}"),
TextFormField(
decoration: InputDecoration(
labelText: "Remove stock",
),
controller: _quantityController,
keyboardType: TextInputType.numberWithOptions(signed: false, decimal: true),
validator: (value) {
if (value.isEmpty) return "Value cannot be empty";
double quantity = double.tryParse(value);
if (quantity == null) return "Value cannot be converted to a number";
if (quantity <= 0) return "Value must be positive";
if (quantity > item.quantity) return "Cannot take more than current quantity";
return null;
},
),
TextFormField(
decoration: InputDecoration(
labelText: "Notes",
),
controller: _notesController,
),
],
)
),
);
}
);
}
void _countStock() async {
Navigator.of(context).pop();
double quantity = double.parse(_quantityController.text);
_quantityController.clear();
var response = await item.countStock(quantity, notes: _notesController.text);
_notesController.clear();
// TODO - Handle error cases
_refresh();
}
void _countStockDialog() async {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text("Count Stock"),
actions: <Widget>[
FlatButton(
child: Text("Count"),
onPressed: () {
if (_countStockKey.currentState.validate()) _countStock();
},
)
],
content: Form(
key: _countStockKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
TextFormField(
decoration: InputDecoration(
labelText: "Count stock",
hintText: "${item.quantity}",
),
controller: _quantityController,
keyboardType: TextInputType.numberWithOptions(signed: false, decimal: true),
validator: (value) {
if (value.isEmpty) return "Value cannot be empty";
double quantity = double.tryParse(value);
if (quantity == null) return "Value cannot be converted to a number";
if (quantity < 0) return "Value cannot be negative";
return null;
},
),
TextFormField(
decoration: InputDecoration(
labelText: "Notes",
),
controller: _notesController,
)
],
)
)
);
}
);
}
void _transferStock(int location) {
// TODO
}
void _transferStockDialog() {
// TODO - Form for transferring stock
}
/*
* Construct a list of detail elements about this StockItem.
* The number of elements may vary depending on the StockItem details
@ -49,7 +309,7 @@ class _StockItemDisplayState extends State<StockDetailWidget> {
),
trailing: IconButton(
icon: FaIcon(FontAwesomeIcons.edit),
onPressed: null,
onPressed: _editStockItemDialog,
)
)
)
@ -73,6 +333,15 @@ class _StockItemDisplayState extends State<StockDetailWidget> {
);
// Quantity information
if (item.isSerialized()) {
tiles.add(
ListTile(
title: Text("Serial Number"),
leading: FaIcon(FontAwesomeIcons.hashtag),
trailing: Text("${item.serialNumber}"),
)
);
} else {
tiles.add(
ListTile(
title: Text("Quantity"),
@ -81,6 +350,9 @@ class _StockItemDisplayState extends State<StockDetailWidget> {
)
);
}
// Location information
if (item.locationName.isNotEmpty) {
tiles.add(
@ -152,6 +424,46 @@ class _StockItemDisplayState extends State<StockDetailWidget> {
return tiles;
}
/*
* Return a list of context-sensitive action buttons.
* Not all buttons will be avaialable for a given StockItem,
* depending on the properties of that StockItem
*/
List<SpeedDialChild> actionButtons() {
var buttons = List<SpeedDialChild>();
// The following actions only apply if the StockItem is not serialized
if (!item.isSerialized()) {
buttons.add(SpeedDialChild(
child: Icon(FontAwesomeIcons.plusCircle),
label: "Add Stock",
onTap: _addStockDialog,
)
);
buttons.add(SpeedDialChild(
child: Icon(FontAwesomeIcons.minusCircle),
label: "Remove Stock",
onTap: _removeStockDialog,
),
);
buttons.add(SpeedDialChild(
child: Icon(FontAwesomeIcons.checkCircle),
label: "Count Stock",
onTap: _countStockDialog,
));
}
buttons.add(SpeedDialChild(
child: Icon(FontAwesomeIcons.exchangeAlt),
label: "Transfer Stock",
onTap: _transferStockDialog,
));
return buttons;
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -159,11 +471,20 @@ class _StockItemDisplayState extends State<StockDetailWidget> {
title: Text("Stock Item"),
),
drawer: new InvenTreeDrawer(context),
floatingActionButton: SpeedDial(
visible: true,
animatedIcon: AnimatedIcons.menu_close,
heroTag: 'stock-item-fab',
children: actionButtons(),
),
body: Center(
child: new RefreshIndicator(
onRefresh: _refresh,
child: ListView(
children: stockTiles(),
)
)
)
);
}
}

View File

@ -83,6 +83,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.7.4"
flutter_speed_dial:
dependency: "direct main"
description:
name: flutter_speed_dial
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.5"
flutter_svg:
dependency: transitive
description:
@ -225,7 +232,7 @@ packages:
name: preferences
url: "https://pub.dartlang.org"
source: hosted
version: "5.1.0"
version: "5.2.0"
qr_utils:
dependency: "direct main"
description:

View File

@ -26,15 +26,12 @@ dependencies:
http: ^0.12.0+2
shared_preferences: ^0.5.3+1
flutter_advanced_networkimage: any
preferences: ^5.1.0
qr_utils: ^0.1.4
package_info: ^0.4.0+16
font_awesome_flutter: ^8.8.1
flutter_advanced_networkimage: any # Pull image from network or cache
preferences: ^5.1.0 # Persistent settings storage
qr_utils: ^0.1.4 # Barcode / QR-code support
package_info: ^0.4.0+16 # App information introspection
font_awesome_flutter: ^8.8.1 # FontAwesome icon set
flutter_speed_dial: ^1.2.5 # FAB menu elements
dev_dependencies:
flutter_test: