Compare commits

...

15 commits

Author SHA1 Message Date
Hendrik Rauh
6380930ac7
Merge pull request #1 from HendrikRauh/login-with-token
Some checks failed
Android / build (push) Has been cancelled
CI / test (push) Has been cancelled
iOS / build (push) Has been cancelled
Implement token-based login method
2026-02-08 18:14:37 +01:00
Raffael Wolf
809d9a3235
Merge branch 'master' into login-with-token 2026-02-08 17:58:24 +01:00
Hendrik Rauh
05a459d451
Merge pull request #5 from HendrikRauh/add-dev-tools
Add Nix configuration files and script for Flutter version management
2026-02-08 17:56:20 +01:00
Raffael Wolf
d07bf90a9a
Merge branch 'master' into login-with-token 2026-02-08 17:42:36 +01:00
HendrikRauh
22f9282191 Update .gitignore to include backup files and correct directory entry 2026-02-08 17:36:36 +01:00
HendrikRauh
b33f5a432b Improve error handling in update-flutter-nix.sh by using 'set -euo pipefail' 2026-02-08 17:21:18 +01:00
HendrikRauh
ccf4482723 Add support for 'dart' command in fvm wrapper script 2026-02-08 17:09:30 +01:00
HendrikRauh
9a30d3dff5 Enhance .envrc for improved Nix environment management fallback 2026-02-08 17:04:46 +01:00
RaffaelW
e08139671d Implement token-based login
Added drop-down to switch between username & password login or token
login so login if using OAuth is possible.
2026-02-08 16:41:48 +01:00
Hendrik Rauh
88512fe447
Merge pull request #4 from inventree/master
update master 0.22.1
2026-02-08 16:38:56 +01:00
Oliver
3c8c263327
Image url fix (#765)
* Bump release notes

* Improve URL resolution

* Add URL tests

* Add debug for CI

* Fix stock adjustment URLs

* Fix barcode URLs
2026-02-05 21:08:12 +11:00
Oliver
934742efda
Bump version (#763) 2026-02-03 21:07:52 +11:00
Oliver
ee553af93b
Part requirements (#761)
* Add setting to control if part requirements are shown

* Split settings for Part and Stock

* Fetch part requirements information

* Add "building" indicator

* Show order allocation progress for part requirements

* Bump release notes

* Remove unused import
2026-02-02 22:52:53 +11:00
Oliver
e38c51e947
Bug fix for list view filtering (#762) 2026-02-02 22:46:48 +11:00
HendrikRauh
8f18e328e0 Add Nix configuration files and script for Flutter version management 2026-01-26 15:54:15 +01:00
21 changed files with 866 additions and 149 deletions

9
.envrc Normal file
View file

@ -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

2
.gitignore vendored
View file

@ -1,4 +1,5 @@
# Miscellaneous # Miscellaneous
*.bak
*.class *.class
*.log *.log
*.pyc *.pyc
@ -6,6 +7,7 @@
.DS_Store .DS_Store
.atom/ .atom/
.buildlog/ .buildlog/
.direnv/
.history .history
.svn/ .svn/

View file

@ -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 - Support display of custom status codes
- Fix default values for list sorting - Fix default values for list sorting
- Fix bug related to null values in list filters
- Updated translations
### 0.21.2 - January 2026 ### 0.21.2 - January 2026
--- ---

78
flake.lock generated Normal file
View file

@ -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
}

113
flake.nix Normal file
View file

@ -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);
};
}
);
}

View file

@ -163,6 +163,7 @@ class InvenTreeFileService extends FileService {
* *
* InvenTree implements token-based authentication, which is * InvenTree implements token-based authentication, which is
* initialised using a username:password combination. * 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; return url;
} }
String _makeUrl(String url) { // Resolve a relative or absolute URL
// Strip leading slash String _makeUrl(String url, {String base = ""}) {
if (url.startsWith("/")) { final baseUri = Uri.parse(base.isNotEmpty ? base : baseUrl);
url = url.substring(1, url.length); final pathUri = Uri.parse(url);
// If path is absolute (has scheme), ignore base
if (pathUri.hasScheme) {
return pathUri.toString();
} }
// Prevent double-slash return baseUri.resolveUri(pathUri).toString();
url = url.replaceAll("//", "/");
return baseUrl + url;
} }
String get apiUrl => _makeUrl("/api/");
String get imageUrl => _makeUrl("/image/");
String makeApiUrl(String endpoint) { String makeApiUrl(String endpoint) {
if (endpoint.startsWith("/api/") || endpoint.startsWith("api/")) { String apiBase = makeUrl("/api/");
return _makeUrl(endpoint);
} else { return _makeUrl(endpoint, base: apiBase);
return _makeUrl("/api/${endpoint}");
}
} }
String get apiUrl => makeApiUrl("");
String makeUrl(String endpoint) => _makeUrl(endpoint); String makeUrl(String endpoint) => _makeUrl(endpoint);
UserProfile? profile; UserProfile? profile;
@ -353,6 +351,9 @@ class InvenTreeAPI {
// Supports separate search against "supplier" / "customer" / "manufacturer" // Supports separate search against "supplier" / "customer" / "manufacturer"
bool get supportsSplitCompanySearch => apiVersion >= 315; 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? // Does the server support the "modern" (consolidated) parameter API?
// Ref: https://github.com/inventree/InvenTree/pull/10699 // Ref: https://github.com/inventree/InvenTree/pull/10699
bool get supportsModernParameters => apiVersion >= 429; bool get supportsModernParameters => apiVersion >= 429;
@ -540,6 +541,40 @@ class InvenTreeAPI {
} }
} }
Future<APIResponse> 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, * Fetch a token from the server,
* with a temporary authentication header * with a temporary authentication header
@ -1140,7 +1175,7 @@ class InvenTreeAPI {
* Perform a request to link a custom barcode to a particular item * Perform a request to link a custom barcode to a particular item
*/ */
Future<bool> linkBarcode(Map<String, String> body) async { Future<bool> linkBarcode(Map<String, String> body) async {
HttpClientRequest? request = await apiRequest("/barcode/link/", "POST"); HttpClientRequest? request = await apiRequest("barcode/link/", "POST");
if (request == null) { if (request == null) {
return false; return false;
@ -1159,7 +1194,7 @@ class InvenTreeAPI {
* Perform a request to unlink a custom barcode from a particular item * Perform a request to unlink a custom barcode from a particular item
*/ */
Future<bool> unlinkBarcode(Map<String, dynamic> body) async { Future<bool> unlinkBarcode(Map<String, dynamic> body) async {
HttpClientRequest? request = await apiRequest("/barcode/unlink/", "POST"); HttpClientRequest? request = await apiRequest("barcode/unlink/", "POST");
if (request == null) { if (request == null) {
return false; return false;

View file

@ -214,6 +214,27 @@ class InvenTreePart extends InvenTreeModel {
}); });
} }
// Request requirements information for this part
Future<InvenTreePartRequirements?> getRequirements() async {
try {
final response = await InvenTreeAPI().get(
"/api/part/${pk}/requirements/",
);
if (response.isValid()) {
final requirementsData = response.data;
if (requirementsData is Map<String, dynamic>) {
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 // Request pricing data for this part
Future<InvenTreePartPricing?> getPricing() async { Future<InvenTreePartPricing?> getPricing() async {
try { try {
@ -437,6 +458,43 @@ class InvenTreePart extends InvenTreeModel {
InvenTreePart.fromJson(json); InvenTreePart.fromJson(json);
} }
/*
* Class representing requirements information for a given Part instance.
*/
class InvenTreePartRequirements extends InvenTreeModel {
InvenTreePartRequirements() : super();
InvenTreePartRequirements.fromJson(Map<String, dynamic> json)
: super.fromJson(json);
@override
List<String> get rolesRequired => ["part"];
@override
InvenTreeModel createFromJson(Map<String, dynamic> 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 { class InvenTreePartPricing extends InvenTreeModel {
InvenTreePartPricing() : super(); InvenTreePartPricing() : super();

View file

@ -534,19 +534,19 @@ class InvenTreeStockItem extends InvenTreeModel {
} }
Future<bool> countStock(double q, {String? notes}) async { Future<bool> 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; return result;
} }
Future<bool> addStock(double q, {String? notes}) async { Future<bool> 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; return result;
} }
Future<bool> removeStock(double q, {String? notes}) async { Future<bool> 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; return result;
} }
@ -563,7 +563,7 @@ class InvenTreeStockItem extends InvenTreeModel {
} }
final bool result = await adjustStock( final bool result = await adjustStock(
"/stock/transfer/", "stock/transfer/",
q, q,
notes: notes, notes: notes,
location: location, location: location,

View file

@ -68,6 +68,12 @@
"allocatedStock": "Allocated Stock", "allocatedStock": "Allocated Stock",
"@allocatedStock": {}, "@allocatedStock": {},
"allocatedToBuildOrders": "Allocated to Build Orders",
"@allocatedToBuildOrders": {},
"allocatedToSalesOrders": "Allocated to Sales Orders",
"@allocatedToSalesOrders": {},
"appReleaseNotes": "Display app release notes", "appReleaseNotes": "Display app release notes",
"@appReleaseNotes": {}, "@appReleaseNotes": {},
@ -226,6 +232,12 @@
"cameraInternalDetail": "Use internal camera to read barcodes", "cameraInternalDetail": "Use internal camera to read barcodes",
"@cameraInternalDetail": {}, "@cameraInternalDetail": {},
"canBuild": "Can Build",
"@canBuild": {},
"canBuildDetail": "Can be produced with current stock",
"@canBuildDetail": {},
"cancel": "Cancel", "cancel": "Cancel",
"@cancel": { "@cancel": {
"description": "Cancel" "description": "Cancel"
@ -418,6 +430,9 @@
"enterPassword": "Enter password", "enterPassword": "Enter password",
"@enterPassword": {}, "@enterPassword": {},
"enterToken": "Enter API token",
"@enterToken": {},
"enterUsername": "Enter username", "enterUsername": "Enter username",
"@enterUsername": {}, "@enterUsername": {},
@ -665,6 +680,9 @@
"invalidSupplierPart": "Invalid Supplier Part", "invalidSupplierPart": "Invalid Supplier Part",
"@invalidSupplierPart": {}, "@invalidSupplierPart": {},
"invalidToken": "Invalid token",
"@invalidToken": {},
"invalidUsernamePassword": "Invalid username / password combination", "invalidUsernamePassword": "Invalid username / password combination",
"@invalidUsernamePassword": {}, "@invalidUsernamePassword": {},
@ -770,6 +788,9 @@
"login": "Login", "login": "Login",
"@login": {}, "@login": {},
"loginMethod": "Login method",
"@loginMethod": {},
"loginEnter": "Enter login details", "loginEnter": "Enter login details",
"@loginEnter": {}, "@loginEnter": {},
@ -936,6 +957,12 @@
"partPricingSettingDetail": "Display part pricing information", "partPricingSettingDetail": "Display part pricing information",
"@pricingSettingDetail": {}, "@pricingSettingDetail": {},
"partRequirements": "Part Requirements",
"@partRequirements": {},
"partRequirementsSettingDetail": "Display part requirements",
"@partRequirementsSettingDetail": {},
"partSettings": "Part Settings", "partSettings": "Part Settings",
"@partSettings": {}, "@partSettings": {},
@ -1535,6 +1562,9 @@
"stockLocations": "Stock Locations", "stockLocations": "Stock Locations",
"@stockLocations": {}, "@stockLocations": {},
"stockSettings": "Stock Settings",
"@stockSettings": {},
"stockTopLevel": "Top level stock location", "stockTopLevel": "Top level stock location",
"@stockTopLevel": {}, "@stockTopLevel": {},
@ -1644,6 +1674,12 @@
"toggleTorch": "Toggle Torch", "toggleTorch": "Toggle Torch",
"@toggleTorch": {}, "@toggleTorch": {},
"token": "Token",
"@token": {},
"tokenEmpty": "Token cannot be empty",
"@tokenEmpty": {},
"tokenError": "Token Error", "tokenError": "Token Error",
"@tokenError": {}, "@tokenError": {},
@ -1720,6 +1756,9 @@
"username": "Username", "username": "Username",
"@username": {}, "@username": {},
"usernameAndPassword": "Username & Password",
"@usernameAndPassword": {},
"usernameEmpty": "Username cannot be empty", "usernameEmpty": "Username cannot be empty",
"@usernameEmpty": {}, "@usernameEmpty": {},

View file

@ -34,6 +34,7 @@ const String INV_LABEL_DEFAULT_PLUGIN = "defaultLabelPlugin";
// Part settings // Part settings
const String INV_PART_SHOW_BOM = "partShowBom"; const String INV_PART_SHOW_BOM = "partShowBom";
const String INV_PART_SHOW_PRICING = "partShowPricing"; const String INV_PART_SHOW_PRICING = "partShowPricing";
const String INV_PART_SHOW_REQUIREMENTS = "partShowRequirements";
// Stock settings // Stock settings
const String INV_STOCK_SHOW_HISTORY = "stockShowHistory"; const String INV_STOCK_SHOW_HISTORY = "stockShowHistory";

View file

@ -8,6 +8,8 @@ import "package:inventree/api.dart";
import "package:inventree/widget/dialogs.dart"; import "package:inventree/widget/dialogs.dart";
import "package:inventree/widget/progress.dart"; import "package:inventree/widget/progress.dart";
enum LoginMethod { credentials, token }
class InvenTreeLoginWidget extends StatefulWidget { class InvenTreeLoginWidget extends StatefulWidget {
const InvenTreeLoginWidget(this.profile) : super(); const InvenTreeLoginWidget(this.profile) : super();
@ -22,9 +24,12 @@ class _InvenTreeLoginState extends State<InvenTreeLoginWidget> {
String username = ""; String username = "";
String password = ""; String password = "";
String token = "";
bool _obscured = true; bool _obscured = true;
LoginMethod _method = LoginMethod.credentials;
String error = ""; String error = "";
// Attempt login // Attempt login
@ -44,12 +49,19 @@ class _InvenTreeLoginState extends State<InvenTreeLoginWidget> {
showLoadingOverlay(); showLoadingOverlay();
// Attempt login final APIResponse response;
final response = await InvenTreeAPI().fetchToken(
widget.profile, if (_method == LoginMethod.credentials) {
username, // Attempt login
password, response = await InvenTreeAPI().fetchToken(
); widget.profile,
username,
password,
);
} else {
// Check token validity
response = await InvenTreeAPI().checkToken(widget.profile, token);
}
hideLoadingOverlay(); hideLoadingOverlay();
@ -123,55 +135,120 @@ class _InvenTreeLoginState extends State<InvenTreeLoginWidget> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
...before, ...before,
TextFormField( // Dropdown to select login method
decoration: InputDecoration( Padding(
labelText: L10().username, padding: EdgeInsets.symmetric(vertical: 8),
labelStyle: TextStyle(fontWeight: FontWeight.bold), child: DropdownButtonFormField<LoginMethod>(
hintText: L10().enterUsername, value: _method,
), decoration: InputDecoration(
initialValue: "", labelText: L10().loginMethod,
keyboardType: TextInputType.text, labelStyle: TextStyle(fontWeight: FontWeight.bold),
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;
});
},
), ),
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, ...after,
], ],
), ),

View file

@ -15,9 +15,7 @@ class _InvenTreePartSettingsState extends State<InvenTreePartSettingsWidget> {
bool partShowBom = true; bool partShowBom = true;
bool partShowPricing = true; bool partShowPricing = true;
bool stockShowHistory = false; bool partShowRequirements = false;
bool stockShowTests = false;
bool stockConfirmScan = false;
@override @override
void initState() { void initState() {
@ -35,16 +33,8 @@ class _InvenTreePartSettingsState extends State<InvenTreePartSettingsWidget> {
INV_PART_SHOW_PRICING, INV_PART_SHOW_PRICING,
true, true,
); );
stockShowHistory = await InvenTreeSettingsManager().getBool( partShowRequirements = await InvenTreeSettingsManager().getBool(
INV_STOCK_SHOW_HISTORY, INV_PART_SHOW_REQUIREMENTS,
false,
);
stockShowTests = await InvenTreeSettingsManager().getBool(
INV_STOCK_SHOW_TESTS,
true,
);
stockConfirmScan = await InvenTreeSettingsManager().getBool(
INV_STOCK_CONFIRM_SCAN,
false, false,
); );
@ -94,54 +84,19 @@ class _InvenTreePartSettingsState extends State<InvenTreePartSettingsWidget> {
}, },
), ),
), ),
Divider(),
ListTile( ListTile(
title: Text(L10().stockItemHistory), title: Text(L10().partRequirements),
subtitle: Text(L10().stockItemHistoryDetail), subtitle: Text(L10().partRequirementsSettingDetail),
leading: Icon(TablerIcons.history), leading: Icon(TablerIcons.list),
trailing: Switch( trailing: Switch(
value: stockShowHistory, value: partShowRequirements,
onChanged: (bool value) { onChanged: (bool value) {
InvenTreeSettingsManager().setValue( InvenTreeSettingsManager().setValue(
INV_STOCK_SHOW_HISTORY, INV_PART_SHOW_REQUIREMENTS,
value, value,
); );
setState(() { setState(() {
stockShowHistory = value; partShowRequirements = 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;
}); });
}, },
), ),

View file

@ -1,5 +1,6 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_tabler_icons/flutter_tabler_icons.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:package_info_plus/package_info_plus.dart";
import "package:inventree/app_colors.dart"; import "package:inventree/app_colors.dart";
@ -119,6 +120,20 @@ class _InvenTreeSettingsState extends State<InvenTreeSettingsWidget> {
); );
}, },
), ),
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( ListTile(
title: Text(L10().purchaseOrder), title: Text(L10().purchaseOrder),
subtitle: Text(L10().purchaseOrderSettings), subtitle: Text(L10().purchaseOrderSettings),

View file

@ -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<InvenTreeStockSettingsWidget> {
_InvenTreeStockSettingsState();
bool stockShowHistory = false;
bool stockShowTests = false;
bool stockConfirmScan = false;
@override
void initState() {
super.initState();
loadSettings();
}
Future<void> 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;
});
},
),
),
],
),
),
);
}
}

View file

@ -61,6 +61,14 @@ abstract class PaginatedSearchState<T extends PaginatedSearchWidget>
backup, backup,
); );
if (result == "null") {
if (tristate) {
return null;
} else {
return backup;
}
}
return result; return result;
} }
@ -69,7 +77,7 @@ abstract class PaginatedSearchState<T extends PaginatedSearchWidget>
final String settings_key = "${prefix}filter_${key}"; final String settings_key = "${prefix}filter_${key}";
if (value == null) { if (value == null) {
await InvenTreeSettingsManager().removeValue(settings_key); await InvenTreeSettingsManager().setValue(settings_key, "null");
} else { } else {
await InvenTreeSettingsManager().setValue(settings_key, value); await InvenTreeSettingsManager().setValue(settings_key, value);
} }

View file

@ -123,10 +123,22 @@ class _PaginatedBomListState extends PaginatedSearchState<PaginatedBomList> {
@override @override
Map<String, Map<String, dynamic>> get filterOptions => { Map<String, Map<String, dynamic>> get filterOptions => {
"sub_part_active": {
"label": L10().filterActive,
"help_text": L10().filterActiveDetail,
"tristate": true,
"default": true,
},
"sub_part_assembly": { "sub_part_assembly": {
"label": L10().filterAssembly, "label": L10().filterAssembly,
"help_text": L10().filterAssemblyDetail, "help_text": L10().filterAssemblyDetail,
}, },
"sub_part_virtual": {
"label": L10().filterVirtual,
"help_text": L10().filterVirtualDetail,
"tristate": true,
"default": true,
},
}; };
@override @override

View file

@ -54,6 +54,7 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
bool allowLabelPrinting = false; bool allowLabelPrinting = false;
bool showBom = false; bool showBom = false;
bool showPricing = false; bool showPricing = false;
bool showRequirements = false;
int parameterCount = 0; int parameterCount = 0;
int attachmentCount = 0; int attachmentCount = 0;
@ -62,6 +63,7 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
int variantCount = 0; int variantCount = 0;
InvenTreePartPricing? partPricing; InvenTreePartPricing? partPricing;
InvenTreePartRequirements? partRequirements;
@override @override
String getAppBarTitle() => L10().partDetails; String getAppBarTitle() => L10().partDetails;
@ -148,6 +150,12 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
final bool result = await part.reload(); final bool result = await part.reload();
// Load page settings from local storage // Load page settings from local storage
showRequirements = await InvenTreeSettingsManager().getBool(
INV_PART_SHOW_REQUIREMENTS,
false,
);
showPricing = await InvenTreeSettingsManager().getBool( showPricing = await InvenTreeSettingsManager().getBool(
INV_PART_SHOW_PRICING, INV_PART_SHOW_PRICING,
true, true,
@ -233,6 +241,23 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
}); });
} }
// 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 show pricing information?
if (showPricing) { if (showPricing) {
part.getPricing().then((InvenTreePartPricing? pricing) { part.getPricing().then((InvenTreePartPricing? pricing) {
@ -242,6 +267,12 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
}); });
} }
}); });
} else {
if (mounted) {
setState(() {
partPricing = null;
});
}
} }
// Request the number of BOM items // Request the number of BOM items
@ -434,6 +465,103 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
); );
} }
// 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) { if (showPricing && partPricing != null) {
String pricing = formatPriceRange( String pricing = formatPriceRange(
partPricing?.overallMin, partPricing?.overallMin,
@ -462,22 +590,6 @@ class _PartDisplayState extends RefreshableState<PartDetailWidget> {
); );
} }
// 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 // Tiles for an "assembly" part
if (part.isAssembly) { if (part.isAssembly) {
if (showBom && bomCount > 0) { if (showBom && bomCount > 0) {

View file

@ -3,6 +3,8 @@ import "dart:io";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_overlay_loader/flutter_overlay_loader.dart"; import "package:flutter_overlay_loader/flutter_overlay_loader.dart";
import "package:inventree/app_colors.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"; 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 * Construct a circular progress indicator
*/ */

View file

@ -1,7 +1,7 @@
name: inventree name: inventree
description: InvenTree stock management description: InvenTree stock management
version: 0.21.2+108 version: 0.22.1+110
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1

View file

@ -23,6 +23,27 @@ void main() {
assert(await UserProfileDBManager().selectProfileByName(testServerName)); 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<String, String> 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:", () { group("Login Tests:", () {
test("Disconnected", () async { test("Disconnected", () async {
// Test that calling disconnect() does the right thing // Test that calling disconnect() does the right thing

54
update-flutter-nix.sh Executable file
View file

@ -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)."