From e35c4df846790c85fa1a8ddc8d2bd01bd74a7cda Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 5 Jul 2022 16:59:32 +1000
Subject: [PATCH 1/4] Allows displays of Bill of Materials for assembled parts

---
 lib/inventree/part.dart     |  3 ---
 lib/widget/part_detail.dart | 33 ++++++++++++++++++++++++++-------
 lib/widget/part_list.dart   | 12 ++++++++----
 3 files changed, 34 insertions(+), 14 deletions(-)

diff --git a/lib/inventree/part.dart b/lib/inventree/part.dart
index 85ee6d86..4b86896e 100644
--- a/lib/inventree/part.dart
+++ b/lib/inventree/part.dart
@@ -303,9 +303,6 @@ class InvenTreePart extends InvenTreeModel {
     // 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"] ?? 0) as int;
-
     // Get the number of BOMs this Part is used in (if it is a component)
     int get usedInCount => (jsondata["used_in"] ?? 0) as int;
 
diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart
index 376d5480..7ac72256 100644
--- a/lib/widget/part_detail.dart
+++ b/lib/widget/part_detail.dart
@@ -2,16 +2,18 @@ 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/inventree/stock.dart";
 import "package:inventree/l10.dart";
 import "package:inventree/helpers.dart";
+import "package:inventree/inventree/part.dart";
+
 import "package:inventree/widget/attachment_widget.dart";
+import "package:inventree/widget/part_list.dart";
 import "package:inventree/widget/part_notes.dart";
 import "package:inventree/widget/progress.dart";
-import "package:inventree/inventree/part.dart";
 import "package:inventree/widget/category_display.dart";
-import "package:inventree/api.dart";
 import "package:inventree/widget/refreshable_state.dart";
 import "package:inventree/widget/part_image_widget.dart";
 import "package:inventree/widget/snacks.dart";
@@ -41,6 +43,8 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
 
   int attachmentCount = 0;
 
+  int bomCount = 0;
+
   @override
   String getAppBarTitle(BuildContext context) => L10().partDetails;
 
@@ -118,6 +122,12 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
         "part": part.pk.toString()
       }
     );
+
+    bomCount = await InvenTreePart().count(
+      filters: {
+        "in_bom_for": part.pk.toString(),
+      }
+    );
   }
 
   Future <void> _toggleStar() async {
@@ -296,14 +306,24 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
     // Tiles for an "assembly" part
     if (part.isAssembly) {
 
-      if (part.bomItemCount > 0) {
+      if (bomCount > 0) {
         tiles.add(
             ListTile(
                 title: Text(L10().billOfMaterials),
-                leading: FaIcon(FontAwesomeIcons.thList),
-                trailing: Text("${part.bomItemCount}"),
+                leading: FaIcon(FontAwesomeIcons.thList, color: COLOR_CLICK),
+                trailing: Text("${bomCount}"),
                 onTap: () {
-                  // TODO
+                  Navigator.push(
+                    context,
+                    MaterialPageRoute(
+                      builder: (context) => PartList(
+                        {
+                          "in_bom_for": "${part.pk}",
+                        },
+                        title: L10().billOfMaterials,
+                      )
+                    )
+                  );
                 }
             )
         );
@@ -583,7 +603,6 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
           icon: FaIcon(FontAwesomeIcons.boxes),
           label: L10().stock
         ),
-        // TODO - Add part actions
         BottomNavigationBarItem(
           icon: FaIcon(FontAwesomeIcons.wrench),
           label: L10().actions,
diff --git a/lib/widget/part_list.dart b/lib/widget/part_list.dart
index 528273f7..2be5e557 100644
--- a/lib/widget/part_list.dart
+++ b/lib/widget/part_list.dart
@@ -12,23 +12,27 @@ import "package:inventree/l10.dart";
 
 class PartList extends StatefulWidget {
 
-  const PartList(this.filters);
+  const PartList(this.filters, {this.title = ""});
+
+  final String title;
 
   final Map<String, String> filters;
 
   @override
-  _PartListState createState() => _PartListState(filters);
+  _PartListState createState() => _PartListState(filters, title);
 }
 
 
 class _PartListState extends RefreshableState<PartList> {
 
-  _PartListState(this.filters);
+  _PartListState(this.filters, this.title);
+
+  final String title;
 
   final Map<String, String> filters;
 
   @override
-  String getAppBarTitle(BuildContext context) => L10().parts;
+  String getAppBarTitle(BuildContext context) => title.isNotEmpty ? title : L10().parts;
 
   @override
   Widget getBody(BuildContext context) {

From 62df40f4b3ff5cd660af63e52cb3f8b8207b22a2 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 5 Jul 2022 17:14:00 +1000
Subject: [PATCH 2/4] Display "variants" in part detail view

---
 lib/l10n/app_en.arb         |  3 +++
 lib/widget/part_detail.dart | 38 +++++++++++++++++++++++++++++++++++--
 2 files changed, 39 insertions(+), 2 deletions(-)

diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 332692ea..83db3d4b 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -1119,6 +1119,9 @@
   "valueRequired": "Value is required",
   "@valueRequired": {},
 
+  "variants": "Variants",
+  "@variants": {},
+
   "version": "Version",
   "@version": {},
 
diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart
index 7ac72256..2621725f 100644
--- a/lib/widget/part_detail.dart
+++ b/lib/widget/part_detail.dart
@@ -45,6 +45,8 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
 
   int bomCount = 0;
 
+  int variantCount = 0;
+
   @override
   String getAppBarTitle(BuildContext context) => L10().partDetails;
 
@@ -128,6 +130,14 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
         "in_bom_for": part.pk.toString(),
       }
     );
+
+    variantCount = await InvenTreePart().count(
+      filters: {
+        "variant_of": part.pk.toString(),
+      }
+    );
+
+    print("Variant count: ${variantCount}");
   }
 
   Future <void> _toggleStar() async {
@@ -271,6 +281,30 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
       );
     }
 
+    // Display number of "variant" parts if any exist
+    if (variantCount > 0) {
+      tiles.add(
+          ListTile(
+            title: Text(L10().variants),
+            leading: FaIcon(FontAwesomeIcons.sitemap, color: COLOR_CLICK),
+            trailing: Text(variantCount.toString()),
+            onTap: () {
+              Navigator.push(
+                  context,
+                  MaterialPageRoute(
+                      builder: (context) => PartList(
+                          {
+                            "variant_of": part.pk.toString(),
+                          },
+                          title: L10().variants
+                      )
+                  )
+              );
+            },
+          )
+      );
+    }
+
     tiles.add(
       ListTile(
         title: Text(L10().availableStock),
@@ -311,14 +345,14 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
             ListTile(
                 title: Text(L10().billOfMaterials),
                 leading: FaIcon(FontAwesomeIcons.thList, color: COLOR_CLICK),
-                trailing: Text("${bomCount}"),
+                trailing: Text(bomCount.toString()),
                 onTap: () {
                   Navigator.push(
                     context,
                     MaterialPageRoute(
                       builder: (context) => PartList(
                         {
-                          "in_bom_for": "${part.pk}",
+                          "in_bom_for": part.pk.toString(),
                         },
                         title: L10().billOfMaterials,
                       )

From 591c6a5592d5e3ceb5f17d420c7eb4b32adef143 Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 5 Jul 2022 18:38:28 +1000
Subject: [PATCH 3/4] Update stock display to indicate allocations

---
 lib/inventree/stock.dart     | 19 ++++++++++++++++---
 lib/l10n/app_en.arb          |  3 +++
 lib/widget/stock_detail.dart |  2 +-
 3 files changed, 20 insertions(+), 4 deletions(-)

diff --git a/lib/inventree/stock.dart b/lib/inventree/stock.dart
index 46b0c75a..0bc826fb 100644
--- a/lib/inventree/stock.dart
+++ b/lib/inventree/stock.dart
@@ -451,7 +451,14 @@ class InvenTreeStockItem extends InvenTreeModel {
 
   String quantityString({bool includeUnits = false}){
 
-    String q = simpleNumberString(quantity);
+    String q = "";
+
+    if (allocated > 0) {
+      q += simpleNumberString(available);
+      q += " / ";
+    }
+
+    q += simpleNumberString(quantity);
 
     if (includeUnits && units.isNotEmpty) {
       q += " ${units}";
@@ -460,6 +467,10 @@ class InvenTreeStockItem extends InvenTreeModel {
     return q;
   }
 
+  double get allocated => double.tryParse(jsondata["allocated"].toString()) ?? 0;
+
+  double get available => quantity - allocated;
+
   int get locationId => (jsondata["location"] ?? -1) as int;
 
   bool isSerialized() => serialNumber.isNotEmpty && quantity.toInt() == 1;
@@ -467,9 +478,11 @@ class InvenTreeStockItem extends InvenTreeModel {
   String serialOrQuantityDisplay() {
     if (isSerialized()) {
       return "SN ${serialNumber}";
+    } else if (allocated > 0) {
+      return "${available} / ${quantity}";
+    } else {
+      return simpleNumberString(quantity);
     }
-
-    return simpleNumberString(quantity);
   }
 
   String get locationName {
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 83db3d4b..8183af6b 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -655,6 +655,9 @@
     "description": "Quantity"
   },
 
+  "quantityAvailable": "Quantity Available",
+  "@quantityAvailable": {},
+
   "quantityEmpty": "Quantity is empty",
   "@quantityEmpty": {},
 
diff --git a/lib/widget/stock_detail.dart b/lib/widget/stock_detail.dart
index d8c43adb..85121036 100644
--- a/lib/widget/stock_detail.dart
+++ b/lib/widget/stock_detail.dart
@@ -513,7 +513,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
     } else {
       tiles.add(
           ListTile(
-            title: Text(L10().quantity),
+            title: item.allocated > 0 ? Text(L10().quantityAvailable) : Text(L10().quantity),
             leading: FaIcon(FontAwesomeIcons.cubes),
             trailing: Text("${item.quantityString()}"),
           )

From 78a5a9090d0caeba00a767622fb199b622dd217e Mon Sep 17 00:00:00 2001
From: Oliver Walters <oliver.henry.walters@gmail.com>
Date: Tue, 5 Jul 2022 19:16:06 +1000
Subject: [PATCH 4/4] Adds new custom widget for displaying Bill of Materials
 data

---
 lib/inventree/bom.dart      |  69 ++++++++++++++++++++++
 lib/inventree/part.dart     |  10 +++-
 lib/widget/bom_list.dart    | 113 ++++++++++++++++++++++++++++++++++++
 lib/widget/part_detail.dart |  19 +++---
 4 files changed, 200 insertions(+), 11 deletions(-)
 create mode 100644 lib/inventree/bom.dart
 create mode 100644 lib/widget/bom_list.dart

diff --git a/lib/inventree/bom.dart b/lib/inventree/bom.dart
new file mode 100644
index 00000000..52ad5782
--- /dev/null
+++ b/lib/inventree/bom.dart
@@ -0,0 +1,69 @@
+
+
+import "package:inventree/inventree/model.dart";
+import "package:inventree/inventree/part.dart";
+
+/*
+ * Class representing the BomItem database model
+ */
+class InvenTreeBomItem extends InvenTreeModel {
+
+  InvenTreeBomItem() : super();
+
+  InvenTreeBomItem.fromJson(Map<String, dynamic> json) : super.fromJson(json);
+
+  @override
+  InvenTreeModel createFromJson(Map<String, dynamic> json) {
+    return InvenTreeBomItem.fromJson(json);
+  }
+
+  @override
+  String get URL => "bom/";
+
+  @override
+  Map<String, String> defaultListFilters() {
+    return {
+      "sub_part_detail": "true",
+    };
+  }
+
+  @override
+  Map<String, String> defaultGetFilters() {
+    return {
+      "sub_part_detail": "true",
+    };
+  }
+
+  // Extract the 'quantity' value associated with this BomItem
+  double get quantity => double.tryParse(jsondata["quantity"].toString()) ?? 0;
+
+  // Extract the ID of the related part
+  int get partId => int.tryParse(jsondata["part"].toString()) ?? -1;
+
+  // Return a Part instance for the referenced part
+  InvenTreePart? get part {
+    if (jsondata.containsKey("part_detail")) {
+      dynamic data = jsondata["part_detail"] ?? {};
+      if (data is Map<String, dynamic>) {
+        return InvenTreePart.fromJson(data);
+      }
+    }
+
+    return null;
+  }
+
+  // Return a Part instance for the referenced sub-part
+  InvenTreePart? get subPart {
+    if (jsondata.containsKey("sub_part_detail")) {
+      dynamic data = jsondata["sub_part_detail"] ?? {};
+      if (data is Map<String, dynamic>) {
+        return InvenTreePart.fromJson(data);
+      }
+    }
+
+    return null;
+}
+
+  // Extract the ID of the related sub-part
+  int get subPartId => int.tryParse(jsondata["sub_part"].toString()) ?? -1;
+}
\ No newline at end of file
diff --git a/lib/inventree/part.dart b/lib/inventree/part.dart
index 4b86896e..8e58eaf2 100644
--- a/lib/inventree/part.dart
+++ b/lib/inventree/part.dart
@@ -10,6 +10,9 @@ import "package:inventree/l10.dart";
 import "package:inventree/inventree/model.dart";
 
 
+/*
+ * Class representing the PartCategory database model
+ */
 class InvenTreePartCategory extends InvenTreeModel {
 
   InvenTreePartCategory() : super();
@@ -70,6 +73,9 @@ class InvenTreePartCategory extends InvenTreeModel {
 }
 
 
+/*
+ * Class representing the PartTestTemplate database model
+ */
 class InvenTreePartTestTemplate extends InvenTreeModel {
 
   InvenTreePartTestTemplate() : super();
@@ -122,6 +128,9 @@ class InvenTreePartTestTemplate extends InvenTreeModel {
 }
 
 
+/*
+ * Class representing the Part database model
+ */
 class InvenTreePart extends InvenTreeModel {
 
   InvenTreePart() : super();
@@ -219,7 +228,6 @@ class InvenTreePart extends InvenTreeModel {
     return _supplierParts;
   }
 
-
   // Cached list of test templates
   List<InvenTreePartTestTemplate> testingTemplates = [];
 
diff --git a/lib/widget/bom_list.dart b/lib/widget/bom_list.dart
new file mode 100644
index 00000000..5e51feeb
--- /dev/null
+++ b/lib/widget/bom_list.dart
@@ -0,0 +1,113 @@
+
+
+import "package:flutter/material.dart";
+
+import "package:inventree/api.dart";
+import "package:inventree/helpers.dart";
+import "package:inventree/inventree/bom.dart";
+import "package:inventree/l10.dart";
+
+import "package:inventree/inventree/model.dart";
+import "package:inventree/inventree/part.dart";
+
+import "package:inventree/widget/paginator.dart";
+import "package:inventree/widget/part_detail.dart";
+import "package:inventree/widget/refreshable_state.dart";
+
+
+/*
+ * Widget for displaying a list of BomItems for the specified 'parent' Part instance
+ */
+class BomList extends StatefulWidget {
+
+  const BomList(this.parent);
+
+  final InvenTreePart parent;
+
+  @override
+  _BomListState createState() => _BomListState(parent);
+
+}
+
+
+class _BomListState extends RefreshableState<BomList> {
+
+  _BomListState(this.parent);
+
+  final InvenTreePart parent;
+
+  @override
+  String getAppBarTitle(BuildContext context) => L10().billOfMaterials;
+
+  @override
+  Widget getBody(BuildContext context) {
+    return PaginatedBomList({
+      "part": parent.pk.toString(),
+    });
+  }
+}
+
+
+/*
+ * Create a paginated widget displaying a list of BomItem objects
+ */
+class PaginatedBomList extends StatefulWidget {
+
+  const PaginatedBomList(this.filters, {this.onTotalChanged});
+
+  final Map<String, String> filters;
+
+  final Function(int)? onTotalChanged;
+
+  @override
+  _PaginatedBomListState createState() => _PaginatedBomListState(filters, onTotalChanged);
+
+}
+
+
+class _PaginatedBomListState extends PaginatedSearchState<PaginatedBomList> {
+
+  _PaginatedBomListState(Map<String, String> filters, this.onTotalChanged) : super(filters);
+
+  Function(int)? onTotalChanged;
+
+  @override
+  Future<InvenTreePageResponse?> requestPage(int limit, int offset, Map<String, String> params) async {
+
+    final page = await InvenTreeBomItem().listPaginated(limit, offset, filters: params);
+
+    return page;
+  }
+
+  @override
+  Widget buildItem(BuildContext context, InvenTreeModel model) {
+
+    InvenTreeBomItem bomItem = model as InvenTreeBomItem;
+
+    InvenTreePart? subPart = bomItem.subPart;
+
+    String title = subPart?.fullname ?? "error - no name";
+    String description = subPart?.description ?? "error - no description";
+
+    return ListTile(
+      title: Text(title),
+      subtitle: Text(description),
+      trailing: Text(
+        simpleNumberString(bomItem.quantity),
+        style: TextStyle(fontWeight: FontWeight.bold),
+      ),
+      leading: InvenTreeAPI().getImage(
+        subPart?.thumbnail ?? "",
+        width: 40,
+        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)));
+          }
+        });
+      },
+    );
+  }
+}
\ No newline at end of file
diff --git a/lib/widget/part_detail.dart b/lib/widget/part_detail.dart
index 2621725f..c7e36b85 100644
--- a/lib/widget/part_detail.dart
+++ b/lib/widget/part_detail.dart
@@ -10,6 +10,7 @@ import "package:inventree/helpers.dart";
 import "package:inventree/inventree/part.dart";
 
 import "package:inventree/widget/attachment_widget.dart";
+import "package:inventree/widget/bom_list.dart";
 import "package:inventree/widget/part_list.dart";
 import "package:inventree/widget/part_notes.dart";
 import "package:inventree/widget/progress.dart";
@@ -136,8 +137,6 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
         "variant_of": part.pk.toString(),
       }
     );
-
-    print("Variant count: ${variantCount}");
   }
 
   Future <void> _toggleStar() async {
@@ -286,7 +285,7 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
       tiles.add(
           ListTile(
             title: Text(L10().variants),
-            leading: FaIcon(FontAwesomeIcons.sitemap, color: COLOR_CLICK),
+            leading: FaIcon(FontAwesomeIcons.shapes, color: COLOR_CLICK),
             trailing: Text(variantCount.toString()),
             onTap: () {
               Navigator.push(
@@ -310,7 +309,12 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
         title: Text(L10().availableStock),
         subtitle: Text(L10().stockDetails),
         leading: FaIcon(FontAwesomeIcons.boxes, color: COLOR_CLICK),
-        trailing: Text(part.stockString()),
+        trailing: Text(
+          part.stockString(),
+          style: TextStyle(
+            fontWeight: FontWeight.bold,
+          ),
+        ),
         onTap: () {
           setState(() {
             tabIndex = 1;
@@ -350,12 +354,7 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
                   Navigator.push(
                     context,
                     MaterialPageRoute(
-                      builder: (context) => PartList(
-                        {
-                          "in_bom_for": part.pk.toString(),
-                        },
-                        title: L10().billOfMaterials,
-                      )
+                      builder: (context) => BomList(part)
                     )
                   );
                 }