diff --git a/lib/api.dart b/lib/api.dart index 885fd145..6d272520 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -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 post(String url, {Map body}) async { + Future post(String url, {Map body}) async { var _url = makeApiUrl(url); - var _headers = defaultHeaders(); - var _body = Map(); + 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 jsonHeaders() { + + var headers = defaultHeaders(); + headers['Content-Type'] = 'application/json'; + return headers; + } + String _authorizationHeader () { if (_token.isNotEmpty) { return "Token $_token"; diff --git a/lib/inventree/model.dart b/lib/inventree/model.dart index 7b62ac0e..a501ce7a 100644 --- a/lib/inventree/model.dart +++ b/lib/inventree/model.dart @@ -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 defaultGetFilters() { return Map(); } + /* + * Reload this object, by requesting data from the server + */ + Future reload() async { + + var response = await api.get(url, params: defaultGetFilters()); + + if (response.statusCode != 200) { + print("Error retrieving data"); + return false; + } + + final Map data = json.decode(response.body); + + jsondata = data; + + return true; + } + // Return the detail view for the associated pk Future get(int pk, {Map 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 results = new List(); diff --git a/lib/inventree/stock.dart b/lib/inventree/stock.dart index 2365b74b..e8cfcf22 100644 --- a/lib/inventree/stock.dart +++ b/lib/inventree/stock.dart @@ -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 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 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 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; } - } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 2f400069..4dd424d9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 { 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 { IconButton( icon: FaIcon(FontAwesomeIcons.search), tooltip: 'Search', - onPressed: null, + onPressed: _search, ), ], ), @@ -271,7 +273,7 @@ class _MyHomePageState extends State { IconButton( icon: new FaIcon(FontAwesomeIcons.search), tooltip: 'Search', - onPressed: _unsupported, + onPressed: _search, ), Text("Search"), ], diff --git a/lib/widget/drawer.dart b/lib/widget/drawer.dart index f5db6e59..3a4536df 100644 --- a/lib/widget/drawer.dart +++ b/lib/widget/drawer.dart @@ -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"), diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart index 13a7d498..e207db40 100644 --- a/lib/widget/part_detail.dart +++ b/lib/widget/part_detail.dart @@ -160,6 +160,11 @@ class _PartDisplayState extends State { 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(), diff --git a/lib/widget/search.dart b/lib/widget/search.dart new file mode 100644 index 00000000..52bf164a --- /dev/null +++ b/lib/widget/search.dart @@ -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 { + + @override + Widget build(BuildContext context) { + + return Scaffold( + appBar: AppBar( + title: Text("Search"), + ), + drawer: new InvenTreeDrawer(context), + body: Center( + child: ListView( + children: [ + + ], + ) + ) + ); + + } +} \ No newline at end of file diff --git a/lib/widget/stock_detail.dart b/lib/widget/stock_detail.dart index 507cb72a..a76873e5 100644 --- a/lib/widget/stock_detail.dart +++ b/lib/widget/stock_detail.dart @@ -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 { + final TextEditingController _quantityController = TextEditingController(); + final TextEditingController _notesController = TextEditingController(); + + final _addStockKey = GlobalKey(); + final _removeStockKey = GlobalKey(); + final _countStockKey = GlobalKey(); + final _moveStockKey = GlobalKey(); + final _editStockKey = GlobalKey(); + _StockItemDisplayState(this.item) { // TODO } final InvenTreeStockItem item; + /** + * Function to reload the page data + */ + Future _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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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 { ), trailing: IconButton( icon: FaIcon(FontAwesomeIcons.edit), - onPressed: null, + onPressed: _editStockItemDialog, ) ) ) @@ -73,13 +333,25 @@ class _StockItemDisplayState extends State { ); // Quantity information - tiles.add( - ListTile( - title: Text("Quantity"), - leading: FaIcon(FontAwesomeIcons.cubes), - trailing: Text("${item.quantity}"), - ) - ); + 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"), + leading: FaIcon(FontAwesomeIcons.cubes), + trailing: Text("${item.quantity}"), + ) + ); + + } + // Location information if (item.locationName.isNotEmpty) { @@ -152,6 +424,46 @@ class _StockItemDisplayState extends State { 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 actionButtons() { + var buttons = List(); + + // 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,9 +471,18 @@ class _StockItemDisplayState extends State { 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: ListView( - children: stockTiles(), + child: new RefreshIndicator( + onRefresh: _refresh, + child: ListView( + children: stockTiles(), + ) ) ) ); diff --git a/pubspec.lock b/pubspec.lock index dde8775c..fa4c5ec7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 2a381194..b34b622b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: