diff --git a/lib/api.dart b/lib/api.dart index 169535ec..a02303f3 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -304,10 +304,17 @@ class InvenTreeAPI { imageUrl = staticImage; } - return new AdvancedNetworkImage(makeUrl(imageUrl), + String url = makeUrl(imageUrl); + + return new AdvancedNetworkImage(url, header: defaultHeaders(), useDiskCache: true, - cacheRule: CacheRule(maxAge: const Duration(days: 5)), + //retryDuration: const Duration(seconds: 2), + //retryLimit: 3, + cacheRule: CacheRule(maxAge: const Duration(days: 1)), + loadFailedCallback: () { + DiskCache().evict(url); + } ); } } \ No newline at end of file diff --git a/lib/inventree/model.dart b/lib/inventree/model.dart index 8bf3bee0..40ef716a 100644 --- a/lib/inventree/model.dart +++ b/lib/inventree/model.dart @@ -41,8 +41,13 @@ class InvenTreeModel { String get description => jsondata['description'] ?? ''; + String get notes => jsondata['notes'] ?? ''; + int get parentId => jsondata['parent'] ?? -1; + // Legacy API provided external link as "URL", while newer API uses "link" + String get link => jsondata['link'] ?? jsondata['URL'] ?? ''; + // Create a new object from JSON data (not a constructor!) InvenTreeModel createFromJson(Map json) { diff --git a/lib/inventree/part.dart b/lib/inventree/part.dart index 5c9403c2..6b09842e 100644 --- a/lib/inventree/part.dart +++ b/lib/inventree/part.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:InvenTree/api.dart'; import 'model.dart'; @@ -12,6 +14,25 @@ class InvenTreePartCategory extends InvenTreeModel { String get pathstring => jsondata['pathstring'] ?? ''; + String get parentpathstring { + // TODO - Drive the refactor tractor through this + List psplit = pathstring.split("/"); + + if (psplit.length > 0) { + psplit.removeLast(); + } + + String p = psplit.join("/"); + + if (p.isEmpty) { + p = "Top level part category"; + } + + return p; + } + + int get partcount => jsondata['parts'] ?? 0; + InvenTreePartCategory() : super(); InvenTreePartCategory.fromJson(Map json) : super.fromJson(json) { @@ -34,14 +55,69 @@ class InvenTreePart extends InvenTreeModel { @override String URL = "part/"; - int get categoryId => jsondata['category'] as int ?? -1; + // Get the number of stock on order for this Part + double get onOrder => double.tryParse(jsondata['on_order'].toString() ?? '0'); - String get categoryName => jsondata['category__name'] ?? ''; + // Get the stock count for this Part + double get inStock => double.tryParse(jsondata['total_stock'].toString() ?? '0'); + // Get the number of units being build for this Part + double get building => double.tryParse(jsondata['building'].toString() ?? '0'); + + // Get the number of BOM items in this Part (if it is an assembly) + int get bomItemCount => jsondata['bom_items'] as int ?? 0; + + // Get the number of BOMs this Part is used in (if it is a component) + int get usedInCount => jsondata['used_in'] as int ?? 0; + + bool get isAssembly => jsondata['assembly'] ?? false; + + bool get isComponent => jsondata['component'] ?? false; + + bool get isPurchaseable => jsondata['purchaseable'] ?? false; + + bool get isSalable => jsondata['salable'] ?? false; + + bool get isActive => jsondata['active'] ?? false; + + bool get isVirtual => jsondata['virtual'] ?? false; + + // Get the IPN (internal part number) for the Part instance + String get IPN => jsondata['IPN'] as String ?? ''; + + // Get the revision string for the Part instance + String get revision => jsondata['revision'] as String ?? ''; + + // Get the category ID for the Part instance (or 'null' if does not exist) + int get categoryId => jsondata['category'] as int ?? null; + + // Get the category name for the Part instance + String get categoryName => jsondata['category_name'] ?? ''; + + // Get the image URL for the Part instance String get _image => jsondata['image'] ?? ''; + // Get the thumbnail URL for the Part instance String get _thumbnail => jsondata['thumbnail'] ?? ''; + // Return the fully-qualified name for the Part instance + String get fullname { + + String fn = jsondata['full_name'] ?? ''; + + if (fn.isNotEmpty) return fn; + + List elements = List(); + + if (IPN.isNotEmpty) elements.add(IPN); + + elements.add(name); + + if (revision.isNotEmpty) elements.add(revision); + + return elements.join(" | "); + } + // Return a path to the image for this Part String get image { // Use thumbnail as a backup diff --git a/lib/inventree/stock.dart b/lib/inventree/stock.dart index 842f32b8..7bfe2bd2 100644 --- a/lib/inventree/stock.dart +++ b/lib/inventree/stock.dart @@ -16,7 +16,13 @@ class InvenTreeStockItem extends InvenTreeModel { String get partDescription => jsondata['part__description'] as String ?? ''; - String get partThumbnail => jsondata['part__thumbnail'] as String ?? InvenTreeAPI.staticThumb; + String get partThumbnail { + String thumb = jsondata['part__thumbnail'] as String ?? ''; + + if (thumb.isEmpty) thumb = InvenTreeAPI.staticThumb; + + return thumb; + } int get serialNumber => jsondata['serial'] as int ?? null; @@ -49,10 +55,31 @@ class InvenTreeStockLocation extends InvenTreeModel { @override String URL = "stock/location/"; + String get pathstring => jsondata['pathstring'] ?? ''; + + String get parentpathstring { + // TODO - Drive the refactor tractor through this + List psplit = pathstring.split('/'); + + if (psplit.length > 0) { + psplit.removeLast(); + } + + String p = psplit.join('/'); + + if (p.isEmpty) { + p = "Top level stock location"; + } + + return p; + } + + int get itemcount => jsondata['items'] ?? 0; + InvenTreeStockLocation() : super(); InvenTreeStockLocation.fromJson(Map json) : super.fromJson(json) { - + // TODO } @override diff --git a/lib/main.dart b/lib/main.dart index f738531c..121fa93a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -194,8 +194,19 @@ class _MyHomePageState extends State { Navigator.push(context, MaterialPageRoute(builder: (context) => LocationDisplayWidget(null))); } - void _suppliers() { - // TODO + void _unsupported() { + showDialog( + context: context, + child: new SimpleDialog( + title: new Text("Unsupported"), + children: [ + ListTile( + title: Text("This feature is not yet supported"), + subtitle: Text("It will be supported in an upcoming release"), + ) + ], + ) + ); } @override @@ -235,7 +246,7 @@ class _MyHomePageState extends State { IconButton( icon: new FaIcon(FontAwesomeIcons.search), tooltip: 'Search', - onPressed: _search, + onPressed: _unsupported, ), Text("Search"), ], @@ -259,7 +270,7 @@ class _MyHomePageState extends State { Column( children: [ IconButton( - icon: new Icon(Icons.category), + icon: new FaIcon(FontAwesomeIcons.shapes), tooltip: 'Parts', onPressed: _parts, ), @@ -281,7 +292,7 @@ class _MyHomePageState extends State { IconButton( icon: new FaIcon(FontAwesomeIcons.industry), tooltip: 'Suppliers', - onPressed: _suppliers, + onPressed: _unsupported, ), Text("Suppliers"), ] @@ -289,6 +300,42 @@ class _MyHomePageState extends State { ], ), Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Column( + children: [ + IconButton( + icon: new FaIcon(FontAwesomeIcons.tools), + tooltip: "Build", + onPressed: _unsupported, + ), + Text("Build"), + ], + ), + Column( + children: [ + IconButton( + icon: new FaIcon(FontAwesomeIcons.shoppingCart), + tooltip: "Order", + onPressed: _unsupported, + ), + Text("Order"), + ] + ), + Column( + children: [ + IconButton( + icon: new FaIcon(FontAwesomeIcons.truck), + tooltip: "Ship", + onPressed: _unsupported, + ), + Text("Ship"), + ] + ) + ], + ), + Spacer(), Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, diff --git a/lib/settings.dart b/lib/settings.dart index 55b28ace..48d8d363 100644 --- a/lib/settings.dart +++ b/lib/settings.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:InvenTree/api.dart'; import 'login_settings.dart'; import 'package:package_info/package_info.dart'; @@ -56,6 +57,11 @@ class _InvenTreeSettingsState extends State { child: new SimpleDialog( title: new Text("About InvenTree"), children: [ + ListTile( + title: Text("Server Version"), + subtitle: Text(InvenTreeAPI().version.isNotEmpty ? InvenTreeAPI().version : "Not connected"), + ), + Divider(), ListTile( title: Text("App Name"), subtitle: Text("${info.appName}"), @@ -72,6 +78,7 @@ class _InvenTreeSettingsState extends State { title: Text("Build Number"), subtitle: Text("${info.buildNumber}") ), + Divider(), ListTile( title: Text("Submit Bug Report"), subtitle: Text("Submit a bug report or feature request at:\n https://github.com/inventree/inventree-app/issues/"), diff --git a/lib/widget/category_display.dart b/lib/widget/category_display.dart index 7a30fd91..0f68703f 100644 --- a/lib/widget/category_display.dart +++ b/lib/widget/category_display.dart @@ -10,6 +10,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_advanced_networkimage/provider.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; class CategoryDisplayWidget extends StatefulWidget { @@ -42,7 +43,7 @@ class _CategoryDisplayState extends State { if (category == null) { return "Part Categories"; } else { - return "Part Category '${category.name}'"; + return "Part Category - ${category.name}"; } } @@ -82,6 +83,51 @@ class _CategoryDisplayState extends State { }); } + bool _subcategoriesExpanded = false; + bool _partListExpanded = true; + + Widget getCategoryDescriptionCard() { + if (category == null) { + return Card( + child: ListTile( + title: Text("Part Categories"), + subtitle: Text("Top level part category"), + ) + ); + } else { + return Card( + child: Column( + children: [ + ListTile( + title: Text("${category.name}"), + subtitle: Text("${category.description}"), + trailing: IconButton( + icon: FaIcon(FontAwesomeIcons.edit), + onPressed: null, + ), + ), + ListTile( + title: Text("Parent Category"), + subtitle: Text("${category.parentpathstring}"), + onTap: () { + if (category.parentId < 0) { + Navigator.push(context, MaterialPageRoute(builder: (context) => CategoryDisplayWidget(null))); + } else { + // TODO - Refactor this code into the InvenTreePart class + InvenTreePartCategory().get(category.parentId).then((var cat) { + if (cat is InvenTreePartCategory) { + Navigator.push(context, MaterialPageRoute(builder: (context) => CategoryDisplayWidget(cat))); + } + }); + } + }, + ) + ] + ), + ); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -89,25 +135,62 @@ class _CategoryDisplayState extends State { title: Text(_titleString), ), drawer: new InvenTreeDrawer(context), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + body: ListView( children: [ - Text( - "Subcategories - ${_subcategories.length}", - textAlign: TextAlign.left, - style: TextStyle(fontWeight: FontWeight.bold), + getCategoryDescriptionCard(), + ExpansionPanelList( + expansionCallback: (int index, bool isExpanded) { + setState(() { + + switch (index) { + case 0: + _subcategoriesExpanded = !isExpanded; + break; + case 1: + _partListExpanded = !isExpanded; + break; + default: + break; + } + }); + }, + children: [ + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return ListTile( + title: Text("Subcategories"), + leading: FaIcon(FontAwesomeIcons.stream), + trailing: Text("${_subcategories.length}"), + onTap: () { + setState(() { + _subcategoriesExpanded = !_subcategoriesExpanded; + }); + }, + ); + }, + body: SubcategoryList(_subcategories), + isExpanded: _subcategoriesExpanded, + ), + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return ListTile( + title: Text("Parts"), + leading: FaIcon(FontAwesomeIcons.shapes), + trailing: Text("${_parts.length}"), + onTap: () { + setState(() { + _partListExpanded = !_partListExpanded; + }); + }, + ); + }, + body: PartList(_parts), + isExpanded: _partListExpanded, + ) + ], ), - Expanded(child: SubcategoryList(_subcategories)), - Divider(), - Text("Parts - ${_parts.length}", - textAlign: TextAlign.left, - style: TextStyle(fontWeight: FontWeight.bold), - ), - Expanded(child: PartList(_parts)), ] ) - ) ); } } @@ -138,6 +221,7 @@ class SubcategoryList extends StatelessWidget { return ListTile( title: Text("${cat.name}"), subtitle: Text("${cat.description}"), + trailing: Text("${cat.partcount}"), onTap: () { _openCategory(context, cat.pk); } @@ -146,7 +230,10 @@ class SubcategoryList extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView.builder(itemBuilder: _build, itemCount: _categories.length); + return ListView.builder( + shrinkWrap: true, + physics: ClampingScrollPhysics(), + itemBuilder: _build, itemCount: _categories.length); } } @@ -192,6 +279,9 @@ class PartList extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView.builder(itemBuilder: _build, itemCount: _parts.length); + return ListView.builder( + shrinkWrap: true, + physics: ClampingScrollPhysics(), + itemBuilder: _build, itemCount: _parts.length); } } diff --git a/lib/widget/location_display.dart b/lib/widget/location_display.dart index 86d8a59c..49654ffb 100644 --- a/lib/widget/location_display.dart +++ b/lib/widget/location_display.dart @@ -5,6 +5,7 @@ import 'package:InvenTree/widget/stock_display.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; class LocationDisplayWidget extends StatefulWidget { @@ -45,12 +46,19 @@ class _LocationDisplayState extends State { String get _title { if (location == null) { - return "Location:"; + return "Stock Locations"; } else { - return "Stock Location '${location.name}'"; + return "Stock Location - ${location.name}"; } } + /* + * Request data from the server. + * It will be displayed once loaded + * + * - List of sublocations under this one + * - List of stock items at this location + */ void _requestData() { int pk = location?.pk ?? -1; @@ -83,6 +91,50 @@ class _LocationDisplayState extends State { }); } + bool _locationListExpanded = false; + bool _stockListExpanded = true; + + Widget locationDescriptionCard() { + if (location == null) { + return Card( + child: ListTile( + title: Text("Stock Locations"), + subtitle: Text("Top level stock location") + ) + ); + } else { + return Card( + child: Column( + children: [ + ListTile( + title: Text("${location.name}"), + subtitle: Text("${location.description}"), + trailing: IconButton( + icon: FaIcon(FontAwesomeIcons.edit), + onPressed: null, + ), + ), + ListTile( + title: Text("Parent Category"), + subtitle: Text("${location.parentpathstring}"), + onTap: () { + if (location.parentId < 0) { + Navigator.push(context, MaterialPageRoute(builder: (context) => LocationDisplayWidget(null))); + } else { + InvenTreeStockLocation().get(location.parentId).then((var loc) { + if (loc is InvenTreeStockLocation) { + Navigator.push(context, MaterialPageRoute(builder: (context) => LocationDisplayWidget(loc))); + } + }); + } + }, + ) + ] + ) + ); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -90,36 +142,62 @@ class _LocationDisplayState extends State { title: Text(_title), ), drawer: new InvenTreeDrawer(context), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - "Sublocations - ${_sublocations.length}", - textAlign: TextAlign.left, - style: TextStyle(fontWeight: FontWeight.bold), - ), - TextField( - decoration: InputDecoration( - hintText: "Filter locations", + body: ListView( + children: [ + locationDescriptionCard(), + ExpansionPanelList( + expansionCallback: (int index, bool isExpanded) { + setState(() { + switch (index) { + case 0: + _locationListExpanded = !isExpanded; + break; + case 1: + _stockListExpanded = !isExpanded; + break; + default: + break; + } + }); + + }, + children: [ + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return ListTile( + title: Text("Sublocations"), + leading: FaIcon(FontAwesomeIcons.mapMarkerAlt), + trailing: Text("${_sublocations.length}"), + onTap: () { + setState(() { + _locationListExpanded = !_locationListExpanded; + }); + }, + ); + }, + body: SublocationList(_sublocations), + isExpanded: _locationListExpanded, ), - onChanged: (text) { - setState(() { - _locationFilter = text.trim().toLowerCase(); - }); - }, - ), - Expanded(child: SublocationList(sublocations)), - Divider(), - Text( - "Stock Items - ${_items.length}", - textAlign: TextAlign.left, - style: TextStyle(fontWeight: FontWeight.bold), - ), - Expanded(child: StockList(_items)), - ], - ) - ), + ExpansionPanel( + headerBuilder: (BuildContext context, bool isExpanded) { + return ListTile( + title: Text("Stock Items"), + leading: FaIcon(FontAwesomeIcons.boxes), + trailing: Text("${_items.length}"), + onTap: () { + setState(() { + _stockListExpanded = !_stockListExpanded; + }); + }, + ); + }, + body: StockList(_items), + isExpanded: _stockListExpanded, + ) + ] + ), + ] + ) ); } } @@ -146,6 +224,7 @@ class SublocationList extends StatelessWidget { return ListTile( title: Text('${loc.name}'), subtitle: Text("${loc.description}"), + trailing: Text("${loc.itemcount}"), onTap: () { _openLocation(context, loc.pk); }, @@ -154,7 +233,10 @@ class SublocationList extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView.builder(itemBuilder: _build, itemCount: _locations.length); + return ListView.builder( + shrinkWrap: true, + physics: ClampingScrollPhysics(), + itemBuilder: _build, itemCount: _locations.length); } } @@ -192,6 +274,9 @@ class StockList extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView.builder(itemBuilder: _build, itemCount: _items.length); + return ListView.builder( + shrinkWrap: true, + physics: ClampingScrollPhysics(), + itemBuilder: _build, itemCount: _items.length); } } \ No newline at end of file diff --git a/lib/widget/part_display.dart b/lib/widget/part_display.dart index 725caf17..712069e6 100644 --- a/lib/widget/part_display.dart +++ b/lib/widget/part_display.dart @@ -1,10 +1,13 @@ import 'package:InvenTree/inventree/part.dart'; +import 'package:InvenTree/widget/category_display.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:InvenTree/api.dart'; import 'package:InvenTree/widget/drawer.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; class PartDisplayWidget extends StatefulWidget { @@ -24,29 +27,176 @@ class _PartDisplayState extends State { // TODO } - final InvenTreePart part; + InvenTreePart part; - String get _title { - if (part == null) { - return "Part"; - } else { - return "Part '${part.name}'"; + /* + * Construct a list of detail elements about this part. + * Not all elements are set for each part, so only add the ones that are important. + */ + List partDetails() { + List widgets = [ + + // Image / name / description + ListTile( + title: Text("${part.fullname}"), + subtitle: Text("${part.description}"), + leading: Image( + image: InvenTreeAPI().getImage(part.image) + ), + trailing: IconButton( + icon: FaIcon(FontAwesomeIcons.edit), + onPressed: null, + ), + ) + ]; + + return widgets; + } + + /* + * Build a list of tiles to display under the part description + */ + List partTiles() { + + List tiles = [ + Card( + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: partDetails(), + ) + ), + ]; + + // Category information + if (part.categoryName.isNotEmpty) { + tiles.add( + Card( + child: ListTile( + title: Text("Part Category"), + subtitle: Text("${part.categoryName}"), + leading: FaIcon(FontAwesomeIcons.stream), + onTap: () { + InvenTreePartCategory().get(part.categoryId).then((var cat) { + Navigator.push(context, MaterialPageRoute(builder: (context) => CategoryDisplayWidget(cat))); + }); + }, + ) + ) + ); } + + // External link? + if (part.link.isNotEmpty) { + tiles.add( + Card( + child: ListTile( + title: Text("${part.link}"), + leading: FaIcon(FontAwesomeIcons.link), + trailing: Text(""), + onTap: null, + ) + ) + ); + } + + // Stock information + tiles.add( + Card( + child: ListTile( + title: Text("Stock"), + leading: FaIcon(FontAwesomeIcons.boxes), + trailing: Text("${part.inStock}"), + onTap: null, + ), + ) + ); + + // Parts on order + if (part.isPurchaseable) { + tiles.add( + Card( + child: ListTile( + title: Text("On Order"), + leading: FaIcon(FontAwesomeIcons.shoppingCart), + trailing: Text("${part.onOrder}"), + onTap: null, + ) + ) + ); + } + + // Parts being built + if (part.isAssembly) { + + tiles.add( + Card( + child: ListTile( + title: Text("Bill of Materials"), + leading: FaIcon(FontAwesomeIcons.thList), + trailing: Text("${part.bomItemCount}"), + onTap: null, + ) + ) + ); + + tiles.add( + Card( + child: ListTile( + title: Text("Building"), + leading: FaIcon(FontAwesomeIcons.tools), + trailing: Text("${part.building}"), + onTap: null, + ) + ) + ); + } + + if (part.isComponent) { + tiles.add( + Card( + child: ListTile( + title: Text("Used In"), + leading: FaIcon(FontAwesomeIcons.sitemap), + trailing: Text("${part.usedInCount}"), + onTap: null, + ) + ) + ); + } + + // Notes field? + if (part.notes.isNotEmpty) { + tiles.add( + Card( + child: ListTile( + title: Text("Notes"), + leading: FaIcon(FontAwesomeIcons.stickyNote), + trailing: Text(""), + onTap: null, + ) + ) + ); + } + + tiles.add(Spacer()); + + return tiles; + } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text(_title), + title: Text("Part Details"), ), drawer: new InvenTreeDrawer(context), body: Center( child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text("Description: ${part.description}"), - ] + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: partTiles(), ), ) );