diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..a374b41 --- /dev/null +++ b/.envrc @@ -0,0 +1,9 @@ +# When nix is available, use the flake for environment management +if command -v nix &> /dev/null; then + if type use_flake &> /dev/null; then + use flake + else + echo "Falling back to nix develop (nix-direnv not available)" + eval "$(nix develop --print-env 2>/dev/null || echo 'echo Failed to load nix environment')" + fi +fi \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8effbf9..061c296 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Miscellaneous +*.bak *.class *.log *.pyc @@ -6,6 +7,7 @@ .DS_Store .atom/ .buildlog/ +.direnv/ .history .svn/ diff --git a/assets/release_notes.md b/assets/release_notes.md index 72324c1..f38d841 100644 --- a/assets/release_notes.md +++ b/assets/release_notes.md @@ -1,9 +1,16 @@ -### x.xx.x - Month Year +## 0.22.1 - February 2026 --- +- Fixes bug related to fetching images from remote URLs + +### 0.22.0 - February 2026 +--- + +- Display overall part requirements on part detail view - Support display of custom status codes - Fix default values for list sorting - +- Fix bug related to null values in list filters +- Updated translations ### 0.21.2 - January 2026 --- diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..e8752c2 --- /dev/null +++ b/flake.lock @@ -0,0 +1,78 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1769170682, + "narHash": "sha256-oMmN1lVQU0F0W2k6OI3bgdzp2YOHWYUAw79qzDSjenU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c5296fdd05cfa2c187990dd909864da9658df755", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-flutter": { + "locked": { + "lastModified": 1751285371, + "narHash": "sha256-/hDU+2AUeFFu5qGHO/UyFMc4UG/x5Cw5uXO36KGTk6c=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b9c03fbbaf84d85bb28eee530c7e9edc4021ca1b", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b9c03fbbaf84d85bb28eee530c7e9edc4021ca1b", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "nixpkgs-flutter": "nixpkgs-flutter" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d1cda89 --- /dev/null +++ b/flake.nix @@ -0,0 +1,113 @@ +{ + description = "InvenTree App Development Environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + nixpkgs-flutter.url = "github:NixOS/nixpkgs/b9c03fbbaf84d85bb28eee530c7e9edc4021ca1b"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + nixpkgs-flutter, + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { + inherit system; + config = { + allowUnfree = true; + android_sdk.accept_license = true; + }; + overlays = [ + (final: prev: { + flutter-pkg = import (self.inputs.nixpkgs-flutter) { inherit system; }; + }) + ]; + }; + + androidSdk = + (pkgs.androidenv.composeAndroidPackages { + platformVersions = [ + "35" + "34" + ]; + buildToolsVersions = [ + "35.0.0" + "34.0.0" + ]; + includeNDK = true; + ndkVersions = [ "26.1.10909125" ]; + cmakeVersions = [ "3.22.1" ]; + }).androidsdk; + + linuxOptionals = with pkgs; [ + gtk3 + glib + pcre + libepoxy + libxkbcommon + dbus + at-spi2-core + file + ]; + + fvmScript = '' + case "$1" in + use) + echo "✓ Using Flutter from Nix ($(flutter --version | head -n1))" + exit 0 + ;; + flutter) + shift + exec flutter "$@" + ;; + dart) + shift + exec dart "$@" + ;; + *) + echo "fvm wrapper: command '$1' not implemented (using Nix-managed Flutter)" >&2 + exit 1 + ;; + esac + ''; + fvm-wrapper = pkgs.writeShellScriptBin "fvm" fvmScript; + in + { + devShells.default = pkgs.mkShell { + buildInputs = + with pkgs; + [ + flutter-pkg.flutter + fvm-wrapper + jdk17 + android-tools + gradle + python3 + python3Packages.invoke + jq + git + curl + unzip + which + ] + ++ lib.optionals stdenv.isLinux linuxOptionals; + + shellHook = '' + export ANDROID_HOME="${androidSdk}/libexec/android-sdk" + export ANDROID_SDK_ROOT="$ANDROID_HOME" + export PATH="$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools/bin:$PATH" + export JAVA_HOME="${pkgs.jdk17}" + export GRADLE_OPTS="-Dorg.gradle.project.android.aapt2FromMavenOverride=$ANDROID_HOME/build-tools/35.0.0/aapt2" + ''; + + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath (with pkgs; linuxOptionals); + }; + } + ); +} diff --git a/lib/api.dart b/lib/api.dart index 61a6379..ab0696a 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -163,6 +163,7 @@ class InvenTreeFileService extends FileService { * * InvenTree implements token-based authentication, which is * initialised using a username:password combination. + * Alternatively, an existing token provided by the user can be used for authentication. */ /* @@ -213,30 +214,27 @@ class InvenTreeAPI { return url; } - String _makeUrl(String url) { - // Strip leading slash - if (url.startsWith("/")) { - url = url.substring(1, url.length); + // Resolve a relative or absolute URL + String _makeUrl(String url, {String base = ""}) { + final baseUri = Uri.parse(base.isNotEmpty ? base : baseUrl); + final pathUri = Uri.parse(url); + + // If path is absolute (has scheme), ignore base + if (pathUri.hasScheme) { + return pathUri.toString(); } - // Prevent double-slash - url = url.replaceAll("//", "/"); - - return baseUrl + url; + return baseUri.resolveUri(pathUri).toString(); } - String get apiUrl => _makeUrl("/api/"); - - String get imageUrl => _makeUrl("/image/"); - String makeApiUrl(String endpoint) { - if (endpoint.startsWith("/api/") || endpoint.startsWith("api/")) { - return _makeUrl(endpoint); - } else { - return _makeUrl("/api/${endpoint}"); - } + String apiBase = makeUrl("/api/"); + + return _makeUrl(endpoint, base: apiBase); } + String get apiUrl => makeApiUrl(""); + String makeUrl(String endpoint) => _makeUrl(endpoint); UserProfile? profile; @@ -353,6 +351,9 @@ class InvenTreeAPI { // Supports separate search against "supplier" / "customer" / "manufacturer" bool get supportsSplitCompanySearch => apiVersion >= 315; + // Supports "requirements" information for specific part + bool get supportsPartRequirements => apiVersion >= 350; + // Does the server support the "modern" (consolidated) parameter API? // Ref: https://github.com/inventree/InvenTree/pull/10699 bool get supportsModernParameters => apiVersion >= 429; @@ -540,6 +541,40 @@ class InvenTreeAPI { } } + Future checkToken(UserProfile userProfile, String token) async { + debug("Checking token @ ${_URL_ME}"); + + userProfile.token = token; + profile = userProfile; + + final response = await get(_URL_ME); + + if (!response.successful()) { + switch (response.statusCode) { + case 401: + case 403: + showServerError( + apiUrl, + L10().serverAuthenticationError, + L10().invalidToken, + ); + default: + showStatusCodeError(apiUrl, response.statusCode); + } + + debug("Request failed: STATUS ${response.statusCode}"); + + // reset token + userProfile.token = ""; + profile = userProfile; + } else { + // save token + await UserProfileDBManager().updateProfile(userProfile); + } + + return response; + } + /* * Fetch a token from the server, * with a temporary authentication header @@ -1140,7 +1175,7 @@ class InvenTreeAPI { * Perform a request to link a custom barcode to a particular item */ Future linkBarcode(Map body) async { - HttpClientRequest? request = await apiRequest("/barcode/link/", "POST"); + HttpClientRequest? request = await apiRequest("barcode/link/", "POST"); if (request == null) { return false; @@ -1159,7 +1194,7 @@ class InvenTreeAPI { * Perform a request to unlink a custom barcode from a particular item */ Future unlinkBarcode(Map body) async { - HttpClientRequest? request = await apiRequest("/barcode/unlink/", "POST"); + HttpClientRequest? request = await apiRequest("barcode/unlink/", "POST"); if (request == null) { return false; diff --git a/lib/inventree/part.dart b/lib/inventree/part.dart index 033fae9..3510d50 100644 --- a/lib/inventree/part.dart +++ b/lib/inventree/part.dart @@ -214,6 +214,27 @@ class InvenTreePart extends InvenTreeModel { }); } + // Request requirements information for this part + Future getRequirements() async { + try { + final response = await InvenTreeAPI().get( + "/api/part/${pk}/requirements/", + ); + if (response.isValid()) { + final requirementsData = response.data; + + if (requirementsData is Map) { + return InvenTreePartRequirements.fromJson(requirementsData); + } + } + } catch (e, stackTrace) { + print("Exception while fetching requirements data for part $pk: $e"); + sentryReportError("getRequirements", e, stackTrace); + } + + return null; + } + // Request pricing data for this part Future getPricing() async { try { @@ -437,6 +458,43 @@ class InvenTreePart extends InvenTreeModel { InvenTreePart.fromJson(json); } +/* + * Class representing requirements information for a given Part instance. + */ +class InvenTreePartRequirements extends InvenTreeModel { + InvenTreePartRequirements() : super(); + + InvenTreePartRequirements.fromJson(Map json) + : super.fromJson(json); + + @override + List get rolesRequired => ["part"]; + + @override + InvenTreeModel createFromJson(Map json) => + InvenTreePartRequirements.fromJson(json); + + // Data accessors + double get canBuild => getDouble("can_build"); + + double get ordering => getDouble("ordering"); + + double get building => getDouble("building"); + + double get scheduledToBuild => getDouble("scheduled_to_build"); + + double get requiredForBuildOrders => getDouble("required_for_build_orders"); + + double get allocatedToBuildOrders => getDouble("allocated_to_build_orders"); + + double get requiredForSalesOrders => getDouble("required_for_sales_orders"); + + double get allocatedToSalesOrders => getDouble("allocated_to_sales_orders"); +} + +/* + * Class representing pricing information for a given Part instance. + */ class InvenTreePartPricing extends InvenTreeModel { InvenTreePartPricing() : super(); diff --git a/lib/inventree/stock.dart b/lib/inventree/stock.dart index 657a218..8ff680a 100644 --- a/lib/inventree/stock.dart +++ b/lib/inventree/stock.dart @@ -534,19 +534,19 @@ class InvenTreeStockItem extends InvenTreeModel { } Future countStock(double q, {String? notes}) async { - final bool result = await adjustStock("/stock/count/", q, notes: notes); + final bool result = await adjustStock("stock/count/", q, notes: notes); return result; } Future addStock(double q, {String? notes}) async { - final bool result = await adjustStock("/stock/add/", q, notes: notes); + final bool result = await adjustStock("stock/add/", q, notes: notes); return result; } Future removeStock(double q, {String? notes}) async { - final bool result = await adjustStock("/stock/remove/", q, notes: notes); + final bool result = await adjustStock("stock/remove/", q, notes: notes); return result; } @@ -563,7 +563,7 @@ class InvenTreeStockItem extends InvenTreeModel { } final bool result = await adjustStock( - "/stock/transfer/", + "stock/transfer/", q, notes: notes, location: location, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4f6560f..98e9122 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -68,6 +68,12 @@ "allocatedStock": "Allocated Stock", "@allocatedStock": {}, + "allocatedToBuildOrders": "Allocated to Build Orders", + "@allocatedToBuildOrders": {}, + + "allocatedToSalesOrders": "Allocated to Sales Orders", + "@allocatedToSalesOrders": {}, + "appReleaseNotes": "Display app release notes", "@appReleaseNotes": {}, @@ -226,6 +232,12 @@ "cameraInternalDetail": "Use internal camera to read barcodes", "@cameraInternalDetail": {}, + "canBuild": "Can Build", + "@canBuild": {}, + + "canBuildDetail": "Can be produced with current stock", + "@canBuildDetail": {}, + "cancel": "Cancel", "@cancel": { "description": "Cancel" @@ -418,6 +430,9 @@ "enterPassword": "Enter password", "@enterPassword": {}, + "enterToken": "Enter API token", + "@enterToken": {}, + "enterUsername": "Enter username", "@enterUsername": {}, @@ -665,6 +680,9 @@ "invalidSupplierPart": "Invalid Supplier Part", "@invalidSupplierPart": {}, + "invalidToken": "Invalid token", + "@invalidToken": {}, + "invalidUsernamePassword": "Invalid username / password combination", "@invalidUsernamePassword": {}, @@ -770,6 +788,9 @@ "login": "Login", "@login": {}, + "loginMethod": "Login method", + "@loginMethod": {}, + "loginEnter": "Enter login details", "@loginEnter": {}, @@ -936,6 +957,12 @@ "partPricingSettingDetail": "Display part pricing information", "@pricingSettingDetail": {}, + "partRequirements": "Part Requirements", + "@partRequirements": {}, + + "partRequirementsSettingDetail": "Display part requirements", + "@partRequirementsSettingDetail": {}, + "partSettings": "Part Settings", "@partSettings": {}, @@ -1535,6 +1562,9 @@ "stockLocations": "Stock Locations", "@stockLocations": {}, + "stockSettings": "Stock Settings", + "@stockSettings": {}, + "stockTopLevel": "Top level stock location", "@stockTopLevel": {}, @@ -1644,6 +1674,12 @@ "toggleTorch": "Toggle Torch", "@toggleTorch": {}, + "token": "Token", + "@token": {}, + + "tokenEmpty": "Token cannot be empty", + "@tokenEmpty": {}, + "tokenError": "Token Error", "@tokenError": {}, @@ -1720,6 +1756,9 @@ "username": "Username", "@username": {}, + "usernameAndPassword": "Username & Password", + "@usernameAndPassword": {}, + "usernameEmpty": "Username cannot be empty", "@usernameEmpty": {}, diff --git a/lib/preferences.dart b/lib/preferences.dart index 92eda1f..a4bc05d 100644 --- a/lib/preferences.dart +++ b/lib/preferences.dart @@ -34,6 +34,7 @@ const String INV_LABEL_DEFAULT_PLUGIN = "defaultLabelPlugin"; // Part settings const String INV_PART_SHOW_BOM = "partShowBom"; const String INV_PART_SHOW_PRICING = "partShowPricing"; +const String INV_PART_SHOW_REQUIREMENTS = "partShowRequirements"; // Stock settings const String INV_STOCK_SHOW_HISTORY = "stockShowHistory"; diff --git a/lib/settings/login.dart b/lib/settings/login.dart index 2e04d49..95d11f1 100644 --- a/lib/settings/login.dart +++ b/lib/settings/login.dart @@ -8,6 +8,8 @@ import "package:inventree/api.dart"; import "package:inventree/widget/dialogs.dart"; import "package:inventree/widget/progress.dart"; +enum LoginMethod { credentials, token } + class InvenTreeLoginWidget extends StatefulWidget { const InvenTreeLoginWidget(this.profile) : super(); @@ -22,9 +24,12 @@ class _InvenTreeLoginState extends State { String username = ""; String password = ""; + String token = ""; bool _obscured = true; + LoginMethod _method = LoginMethod.credentials; + String error = ""; // Attempt login @@ -44,12 +49,19 @@ class _InvenTreeLoginState extends State { showLoadingOverlay(); - // Attempt login - final response = await InvenTreeAPI().fetchToken( - widget.profile, - username, - password, - ); + final APIResponse response; + + if (_method == LoginMethod.credentials) { + // Attempt login + response = await InvenTreeAPI().fetchToken( + widget.profile, + username, + password, + ); + } else { + // Check token validity + response = await InvenTreeAPI().checkToken(widget.profile, token); + } hideLoadingOverlay(); @@ -123,55 +135,120 @@ class _InvenTreeLoginState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ ...before, - TextFormField( - decoration: InputDecoration( - labelText: L10().username, - labelStyle: TextStyle(fontWeight: FontWeight.bold), - hintText: L10().enterUsername, - ), - initialValue: "", - keyboardType: TextInputType.text, - onSaved: (value) { - username = value?.trim() ?? ""; - }, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return L10().usernameEmpty; - } - - return null; - }, - ), - TextFormField( - decoration: InputDecoration( - labelText: L10().password, - labelStyle: TextStyle(fontWeight: FontWeight.bold), - hintText: L10().enterPassword, - suffixIcon: IconButton( - icon: _obscured - ? Icon(TablerIcons.eye) - : Icon(TablerIcons.eye_off), - onPressed: () { - setState(() { - _obscured = !_obscured; - }); - }, + // Dropdown to select login method + Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: DropdownButtonFormField( + value: _method, + decoration: InputDecoration( + labelText: L10().loginMethod, + labelStyle: TextStyle(fontWeight: FontWeight.bold), ), + items: [ + DropdownMenuItem( + child: Text(L10().usernameAndPassword), + value: LoginMethod.credentials, + ), + DropdownMenuItem( + child: Text(L10().token), + value: LoginMethod.token, + ), + ], + onChanged: (val) { + setState(() { + _method = val ?? LoginMethod.credentials; + error = ""; + }); + }, ), - initialValue: "", - keyboardType: TextInputType.visiblePassword, - obscureText: _obscured, - onSaved: (value) { - password = value?.trim() ?? ""; - }, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return L10().passwordEmpty; - } - - return null; - }, ), + + // Show fields depending on selected login method + if (_method == LoginMethod.credentials) ...[ + TextFormField( + key: ValueKey("input-username"), + decoration: InputDecoration( + labelText: L10().username, + labelStyle: TextStyle(fontWeight: FontWeight.bold), + hintText: L10().enterUsername, + ), + initialValue: "", + keyboardType: TextInputType.text, + onSaved: (value) { + username = value?.trim() ?? ""; + }, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return L10().usernameEmpty; + } + + return null; + }, + ), + TextFormField( + key: ValueKey("input-password"), + decoration: InputDecoration( + labelText: L10().password, + labelStyle: TextStyle(fontWeight: FontWeight.bold), + hintText: L10().enterPassword, + suffixIcon: IconButton( + icon: _obscured + ? Icon(TablerIcons.eye) + : Icon(TablerIcons.eye_off), + onPressed: () { + setState(() { + _obscured = !_obscured; + }); + }, + ), + ), + initialValue: "", + keyboardType: TextInputType.visiblePassword, + obscureText: _obscured, + onSaved: (value) { + password = value?.trim() ?? ""; + }, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return L10().passwordEmpty; + } + + return null; + }, + ), + ] else ...[ + TextFormField( + key: ValueKey("input-token"), + decoration: InputDecoration( + labelText: L10().token, + labelStyle: TextStyle(fontWeight: FontWeight.bold), + hintText: L10().enterToken, + suffixIcon: IconButton( + icon: _obscured + ? Icon(TablerIcons.eye) + : Icon(TablerIcons.eye_off), + onPressed: () { + setState(() { + _obscured = !_obscured; + }); + }, + ), + ), + initialValue: "", + keyboardType: TextInputType.visiblePassword, + obscureText: _obscured, + onSaved: (value) { + token = value?.trim() ?? ""; + }, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return L10().tokenEmpty; + } + + return null; + }, + ), + ], ...after, ], ), diff --git a/lib/settings/part_settings.dart b/lib/settings/part_settings.dart index aa13678..fdb5c45 100644 --- a/lib/settings/part_settings.dart +++ b/lib/settings/part_settings.dart @@ -15,9 +15,7 @@ class _InvenTreePartSettingsState extends State { bool partShowBom = true; bool partShowPricing = true; - bool stockShowHistory = false; - bool stockShowTests = false; - bool stockConfirmScan = false; + bool partShowRequirements = false; @override void initState() { @@ -35,16 +33,8 @@ class _InvenTreePartSettingsState extends State { INV_PART_SHOW_PRICING, true, ); - stockShowHistory = await InvenTreeSettingsManager().getBool( - INV_STOCK_SHOW_HISTORY, - false, - ); - stockShowTests = await InvenTreeSettingsManager().getBool( - INV_STOCK_SHOW_TESTS, - true, - ); - stockConfirmScan = await InvenTreeSettingsManager().getBool( - INV_STOCK_CONFIRM_SCAN, + partShowRequirements = await InvenTreeSettingsManager().getBool( + INV_PART_SHOW_REQUIREMENTS, false, ); @@ -94,54 +84,19 @@ class _InvenTreePartSettingsState extends State { }, ), ), - Divider(), ListTile( - title: Text(L10().stockItemHistory), - subtitle: Text(L10().stockItemHistoryDetail), - leading: Icon(TablerIcons.history), + title: Text(L10().partRequirements), + subtitle: Text(L10().partRequirementsSettingDetail), + leading: Icon(TablerIcons.list), trailing: Switch( - value: stockShowHistory, + value: partShowRequirements, onChanged: (bool value) { InvenTreeSettingsManager().setValue( - INV_STOCK_SHOW_HISTORY, + INV_PART_SHOW_REQUIREMENTS, value, ); setState(() { - stockShowHistory = value; - }); - }, - ), - ), - ListTile( - title: Text(L10().testResults), - subtitle: Text(L10().testResultsDetail), - leading: Icon(TablerIcons.test_pipe), - trailing: Switch( - value: stockShowTests, - onChanged: (bool value) { - InvenTreeSettingsManager().setValue( - INV_STOCK_SHOW_TESTS, - value, - ); - setState(() { - stockShowTests = value; - }); - }, - ), - ), - ListTile( - title: Text(L10().confirmScan), - subtitle: Text(L10().confirmScanDetail), - leading: Icon(TablerIcons.qrcode), - trailing: Switch( - value: stockConfirmScan, - onChanged: (bool value) { - InvenTreeSettingsManager().setValue( - INV_STOCK_CONFIRM_SCAN, - value, - ); - setState(() { - stockConfirmScan = value; + partShowRequirements = value; }); }, ), diff --git a/lib/settings/settings.dart b/lib/settings/settings.dart index 3815127..15cd37a 100644 --- a/lib/settings/settings.dart +++ b/lib/settings/settings.dart @@ -1,5 +1,6 @@ import "package:flutter/material.dart"; import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; +import "package:inventree/settings/stock_settings.dart"; import "package:package_info_plus/package_info_plus.dart"; import "package:inventree/app_colors.dart"; @@ -119,6 +120,20 @@ class _InvenTreeSettingsState extends State { ); }, ), + ListTile( + title: Text(L10().stock), + subtitle: Text(L10().stockSettings), + leading: Icon(TablerIcons.packages, color: COLOR_ACTION), + trailing: LinkIcon(), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => InvenTreeStockSettingsWidget(), + ), + ); + }, + ), ListTile( title: Text(L10().purchaseOrder), subtitle: Text(L10().purchaseOrderSettings), diff --git a/lib/settings/stock_settings.dart b/lib/settings/stock_settings.dart new file mode 100644 index 0000000..3856d23 --- /dev/null +++ b/lib/settings/stock_settings.dart @@ -0,0 +1,110 @@ +import "package:flutter/material.dart"; +import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; +import "package:inventree/app_colors.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/preferences.dart"; + +class InvenTreeStockSettingsWidget extends StatefulWidget { + @override + _InvenTreeStockSettingsState createState() => _InvenTreeStockSettingsState(); +} + +class _InvenTreeStockSettingsState extends State { + _InvenTreeStockSettingsState(); + + bool stockShowHistory = false; + bool stockShowTests = false; + bool stockConfirmScan = false; + + @override + void initState() { + super.initState(); + loadSettings(); + } + + Future loadSettings() async { + stockShowHistory = await InvenTreeSettingsManager().getBool( + INV_STOCK_SHOW_HISTORY, + false, + ); + stockShowTests = await InvenTreeSettingsManager().getBool( + INV_STOCK_SHOW_TESTS, + true, + ); + stockConfirmScan = await InvenTreeSettingsManager().getBool( + INV_STOCK_CONFIRM_SCAN, + false, + ); + + if (mounted) { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(L10().stockSettings), + backgroundColor: COLOR_APP_BAR, + ), + body: Container( + child: ListView( + children: [ + ListTile( + title: Text(L10().stockItemHistory), + subtitle: Text(L10().stockItemHistoryDetail), + leading: Icon(TablerIcons.history), + trailing: Switch( + value: stockShowHistory, + onChanged: (bool value) { + InvenTreeSettingsManager().setValue( + INV_STOCK_SHOW_HISTORY, + value, + ); + setState(() { + stockShowHistory = value; + }); + }, + ), + ), + ListTile( + title: Text(L10().testResults), + subtitle: Text(L10().testResultsDetail), + leading: Icon(TablerIcons.test_pipe), + trailing: Switch( + value: stockShowTests, + onChanged: (bool value) { + InvenTreeSettingsManager().setValue( + INV_STOCK_SHOW_TESTS, + value, + ); + setState(() { + stockShowTests = value; + }); + }, + ), + ), + ListTile( + title: Text(L10().confirmScan), + subtitle: Text(L10().confirmScanDetail), + leading: Icon(TablerIcons.qrcode), + trailing: Switch( + value: stockConfirmScan, + onChanged: (bool value) { + InvenTreeSettingsManager().setValue( + INV_STOCK_CONFIRM_SCAN, + value, + ); + setState(() { + stockConfirmScan = value; + }); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widget/paginator.dart b/lib/widget/paginator.dart index 123c7ab..6bbf355 100644 --- a/lib/widget/paginator.dart +++ b/lib/widget/paginator.dart @@ -61,6 +61,14 @@ abstract class PaginatedSearchState backup, ); + if (result == "null") { + if (tristate) { + return null; + } else { + return backup; + } + } + return result; } @@ -69,7 +77,7 @@ abstract class PaginatedSearchState final String settings_key = "${prefix}filter_${key}"; if (value == null) { - await InvenTreeSettingsManager().removeValue(settings_key); + await InvenTreeSettingsManager().setValue(settings_key, "null"); } else { await InvenTreeSettingsManager().setValue(settings_key, value); } diff --git a/lib/widget/part/bom_list.dart b/lib/widget/part/bom_list.dart index 5e17e53..0e87506 100644 --- a/lib/widget/part/bom_list.dart +++ b/lib/widget/part/bom_list.dart @@ -123,10 +123,22 @@ class _PaginatedBomListState extends PaginatedSearchState { @override Map> get filterOptions => { + "sub_part_active": { + "label": L10().filterActive, + "help_text": L10().filterActiveDetail, + "tristate": true, + "default": true, + }, "sub_part_assembly": { "label": L10().filterAssembly, "help_text": L10().filterAssemblyDetail, }, + "sub_part_virtual": { + "label": L10().filterVirtual, + "help_text": L10().filterVirtualDetail, + "tristate": true, + "default": true, + }, }; @override diff --git a/lib/widget/part/part_detail.dart b/lib/widget/part/part_detail.dart index 73a299f..08c6df5 100644 --- a/lib/widget/part/part_detail.dart +++ b/lib/widget/part/part_detail.dart @@ -54,6 +54,7 @@ class _PartDisplayState extends RefreshableState { bool allowLabelPrinting = false; bool showBom = false; bool showPricing = false; + bool showRequirements = false; int parameterCount = 0; int attachmentCount = 0; @@ -62,6 +63,7 @@ class _PartDisplayState extends RefreshableState { int variantCount = 0; InvenTreePartPricing? partPricing; + InvenTreePartRequirements? partRequirements; @override String getAppBarTitle() => L10().partDetails; @@ -148,6 +150,12 @@ class _PartDisplayState extends RefreshableState { final bool result = await part.reload(); // Load page settings from local storage + + showRequirements = await InvenTreeSettingsManager().getBool( + INV_PART_SHOW_REQUIREMENTS, + false, + ); + showPricing = await InvenTreeSettingsManager().getBool( INV_PART_SHOW_PRICING, true, @@ -233,6 +241,23 @@ class _PartDisplayState extends RefreshableState { }); } + // If show requirements information? + if (showRequirements && api.supportsPartRequirements) { + part.getRequirements().then((InvenTreePartRequirements? requirements) { + if (mounted) { + setState(() { + partRequirements = requirements; + }); + } + }); + } else { + if (mounted) { + setState(() { + partRequirements = null; + }); + } + } + // If show pricing information? if (showPricing) { part.getPricing().then((InvenTreePartPricing? pricing) { @@ -242,6 +267,12 @@ class _PartDisplayState extends RefreshableState { }); } }); + } else { + if (mounted) { + setState(() { + partPricing = null; + }); + } } // Request the number of BOM items @@ -434,6 +465,103 @@ class _PartDisplayState extends RefreshableState { ); } + // Part "requirements" + if (showRequirements && + api.supportsPartRequirements && + partRequirements != null) { + // Assembly parts + if (part.isAssembly) { + // Scheduled to build + if (partRequirements!.building > 0 || + partRequirements!.scheduledToBuild > 0) { + tiles.add( + ListTile( + title: Text(L10().building), + subtitle: ProgressBar( + partRequirements!.building, + maximum: partRequirements!.scheduledToBuild, + ), + leading: Icon(TablerIcons.tools), + trailing: ProgressText( + partRequirements!.building, + maximum: partRequirements!.scheduledToBuild, + ), + ), + ); + } + + // Can build + if (part.isActive) { + tiles.add( + ListTile( + title: Text(L10().canBuild), + subtitle: Text(L10().canBuildDetail), + trailing: LargeText( + simpleNumberString(partRequirements!.canBuild), + ), + leading: Icon(TablerIcons.check), + ), + ); + } + } + + // Build requirements + if (partRequirements!.requiredForBuildOrders > 0 || + partRequirements!.allocatedToBuildOrders > 0) { + tiles.add( + ListTile( + title: Text(L10().allocatedToBuildOrders), + subtitle: ProgressBar( + partRequirements!.allocatedToBuildOrders, + maximum: partRequirements!.requiredForBuildOrders, + ), + trailing: ProgressText( + partRequirements!.allocatedToBuildOrders, + maximum: partRequirements!.requiredForBuildOrders, + ), + leading: Icon(TablerIcons.tools), + ), + ); + } + + // Sales requirements + if (part.isSalable) { + if (partRequirements!.requiredForSalesOrders > 0 || + partRequirements!.allocatedToSalesOrders > 0) { + tiles.add( + ListTile( + title: Text(L10().allocatedToSalesOrders), + subtitle: ProgressBar( + partRequirements!.allocatedToSalesOrders, + maximum: partRequirements!.requiredForSalesOrders, + ), + trailing: ProgressText( + partRequirements!.allocatedToSalesOrders, + maximum: partRequirements!.requiredForSalesOrders, + ), + leading: Icon(TablerIcons.truck_delivery), + ), + ); + } + } + + // Ordering stats + if (part.isPurchaseable && partRequirements!.ordering > 0) { + // On order + tiles.add( + ListTile( + title: Text(L10().onOrder), + subtitle: Text(L10().onOrderDetails), + leading: Icon(TablerIcons.shopping_cart), + trailing: LargeText("${part.onOrderString}"), + onTap: () { + // TODO - Order views + }, + ), + ); + } + } + if (showPricing && partPricing != null) { String pricing = formatPriceRange( partPricing?.overallMin, @@ -462,22 +590,6 @@ class _PartDisplayState extends RefreshableState { ); } - // Tiles for "purchaseable" parts - if (part.isPurchaseable) { - // On order - tiles.add( - ListTile( - title: Text(L10().onOrder), - subtitle: Text(L10().onOrderDetails), - leading: Icon(TablerIcons.shopping_cart), - trailing: LargeText("${part.onOrderString}"), - onTap: () { - // TODO - Order views - }, - ), - ); - } - // Tiles for an "assembly" part if (part.isAssembly) { if (showBom && bomCount > 0) { diff --git a/lib/widget/progress.dart b/lib/widget/progress.dart index a95ace9..bb043ad 100644 --- a/lib/widget/progress.dart +++ b/lib/widget/progress.dart @@ -3,6 +3,8 @@ import "dart:io"; import "package:flutter/material.dart"; import "package:flutter_overlay_loader/flutter_overlay_loader.dart"; import "package:inventree/app_colors.dart"; +import "package:inventree/helpers.dart"; +import "package:inventree/widget/link_icon.dart"; import "package:one_context/one_context.dart"; /* @@ -25,6 +27,15 @@ Widget ProgressBar(double value, {double maximum = 1.0}) { ); } +Widget ProgressText(double value, {double maximum = 1.0}) { + Color textColor = value < maximum ? COLOR_WARNING : COLOR_SUCCESS; + + String v = simpleNumberString(value); + String m = simpleNumberString(maximum); + + return LargeText("${v} / ${m}", color: textColor); +} + /* * Construct a circular progress indicator */ diff --git a/pubspec.yaml b/pubspec.yaml index 129d635..14f931f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: inventree description: InvenTree stock management -version: 0.21.2+108 +version: 0.22.1+110 environment: sdk: ^3.8.1 diff --git a/test/api_test.dart b/test/api_test.dart index f19dbf4..3bd5cdb 100644 --- a/test/api_test.dart +++ b/test/api_test.dart @@ -23,6 +23,27 @@ void main() { assert(await UserProfileDBManager().selectProfileByName(testServerName)); }); + // Ensure that generated URLs are correct + group("URL Tests:", () { + test("Generate URLs", () async { + UserProfile profile = await setupServerProfile(); + var api = InvenTreeAPI(); + + api.profile = profile; + + Map tests = { + "": "http://localhost:8000/api/", + "barcode/": "http://localhost:8000/api/barcode/", + "https://remote-server.com/media/image.png": + "https://remote-server.com/media/image.png", + }; + + for (var test in tests.entries) { + expect(api.makeApiUrl(test.key), equals(test.value)); + } + }); + }); + group("Login Tests:", () { test("Disconnected", () async { // Test that calling disconnect() does the right thing diff --git a/update-flutter-nix.sh b/update-flutter-nix.sh new file mode 100755 index 0000000..1f60593 --- /dev/null +++ b/update-flutter-nix.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +# This script adjusts flake.nix to use the flutter version +# pinned in .fvmrc. It fetches the appropriate commit hash. + +set -euo pipefail + +for cmd in jq curl sed nix; do + command -v $cmd >/dev/null 2>&1 || { echo "Error: $cmd is not installed!"; exit 1; } +done + +if [ ! -f .fvmrc ]; then + echo ".fvmrc file not found!" + exit 1 +fi + +FLUTTER_VERSION=$(jq -r .flutter .fvmrc) +echo "Requested Flutter version: $FLUTTER_VERSION" + +API_URL="https://search.devbox.sh/v2/pkg?name=flutter" +RELEASES_JSON=$(curl -s "$API_URL") + +ALL_VERSIONS=$(echo "$RELEASES_JSON" | jq -r '.releases[].version' | sort -V) + +FOUND_VERSION=$(echo "$ALL_VERSIONS" | awk -v v="$FLUTTER_VERSION" '$0 >= v { print; exit }') + +if [ -z "$FOUND_VERSION" ]; then + echo "Error: No matching Flutter version found." + exit 1 +fi + +if [ "$FOUND_VERSION" != "$FLUTTER_VERSION" ]; then + echo "Note: Exact version not found, using version $FOUND_VERSION instead." +fi + +COMMIT=$(echo "$RELEASES_JSON" | jq -r --arg v "$FOUND_VERSION" '.releases[] | select(.version==$v) | .platforms[] | select(.system=="x86_64-linux") | .commit_hash' | head -n 1) + +if [ -z "$COMMIT" ] || [ "$COMMIT" == "null" ]; then + echo "Error: No commit hash found for version $FOUND_VERSION and platform x86_64-linux." + exit 1 +fi + +echo "Found commit: $COMMIT (Version: $FOUND_VERSION)" + + +sed -i.bak "s|nixpkgs-flutter.url = \"github:NixOS/nixpkgs/[a-f0-9]*\";|nixpkgs-flutter.url = \"github:NixOS/nixpkgs/$COMMIT\";|" flake.nix + +nix flake update nixpkgs-flutter + +if command -v direnv >/dev/null 2>&1; then + direnv reload +fi + +echo "Success! flake.nix now uses the commit for Flutter $FLUTTER_VERSION (or higher)."