UI updates (#849)

* Updated dashboard

- Display "outstanding" badges
- Display "overdue" badges
- Pull-to-refresh

* Remove dead code

* Refactor app colors

* Add "pending shipments" icon

* Remove custom spinner

* Refactor error dialog

* Updated redirect after login

* Refactor login/account pages

- Better UX and confirmation messages

* Refactor API error messages

* Improve token management

- Secure storage
- Handle session expiry
- Per-profile HTTPS certificate checks

* Improved error messages
This commit is contained in:
Oliver
2026-07-06 21:10:10 +10:00
committed by GitHub
parent f737172180
commit 349a8e0ef5
45 changed files with 1240 additions and 477 deletions
+219 -47
View File
@@ -25,6 +25,7 @@ import "package:inventree/inventree/model.dart";
import "package:inventree/inventree/notification.dart"; import "package:inventree/inventree/notification.dart";
import "package:inventree/inventree/status_codes.dart"; import "package:inventree/inventree/status_codes.dart";
import "package:inventree/inventree/sentry.dart"; import "package:inventree/inventree/sentry.dart";
import "package:inventree/settings/login.dart";
import "package:inventree/user_profile.dart"; import "package:inventree/user_profile.dart";
import "package:inventree/widget/dialogs.dart"; import "package:inventree/widget/dialogs.dart";
import "package:inventree/widget/snacks.dart"; import "package:inventree/widget/snacks.dart";
@@ -191,8 +192,11 @@ class InvenTreeAPI {
} }
} }
// Minimum required API version for server // Minimum required API version for server.
// 2023-03-04 // Deliberately kept low/permissive: individual features are gated by their
// own apiVersion checks below (up to 496 at last count), so this floor
// only needs to reject servers old enough to predate those checks
// entirely, not enforce feature parity. Last reviewed 2026-07-06.
static const _minApiVersion = 100; static const _minApiVersion = 100;
bool _strictHttps = false; bool _strictHttps = false;
@@ -430,16 +434,34 @@ class InvenTreeAPI {
} }
if (!await _checkAuth()) { if (!await _checkAuth()) {
showServerError( final UserProfile? expiredProfile = profile;
_URL_ME,
L10().serverNotConnected,
L10().serverAuthenticationError,
);
// Invalidate the token // Invalidate the token
if (profile != null) { if (expiredProfile != null) {
profile!.token = ""; expiredProfile.token = "";
await UserProfileDBManager().updateProfile(profile!); await UserProfileDBManager().updateProfile(expiredProfile);
}
if (expiredProfile != null) {
showServerError(
_URL_ME,
L10().sessionExpired,
"${L10().sessionExpiredDetail}\n${expiredProfile.name}",
);
// Take the user straight to the login screen for this profile,
// rather than leaving them to work out why Home shows disconnected
OneContext().push(
MaterialPageRoute(
builder: (context) => InvenTreeLoginWidget(expiredProfile),
),
);
} else {
showServerError(
_URL_ME,
L10().serverNotConnected,
L10().serverAuthenticationError,
);
} }
return false; return false;
@@ -557,8 +579,9 @@ class InvenTreeAPI {
Future<APIResponse> fetchToken( Future<APIResponse> fetchToken(
UserProfile userProfile, UserProfile userProfile,
String username, String username,
String password, String password, {
) async { bool showDialog = true,
}) async {
debug("Fetching user token from ${userProfile.server}"); debug("Fetching user token from ${userProfile.server}");
profile = userProfile; profile = userProfile;
@@ -609,18 +632,22 @@ class InvenTreeAPI {
headers: {HttpHeaders.authorizationHeader: authHeader}, headers: {HttpHeaders.authorizationHeader: authHeader},
); );
final data = response.asMap();
// Invalid response // Invalid response
if (!response.successful()) { if (!response.successful()) {
switch (response.statusCode) { if (showDialog) {
case 401: switch (response.statusCode) {
case 403: case 401:
showServerError( case 403:
apiUrl, showServerError(
L10().serverAuthenticationError, apiUrl,
L10().invalidUsernamePassword, L10().serverAuthenticationError,
); L10().invalidUsernamePassword,
default: );
showStatusCodeError(apiUrl, response.statusCode); default:
showStatusCodeError(apiUrl, response.statusCode);
}
} }
debug("Token request failed: STATUS ${response.statusCode}"); debug("Token request failed: STATUS ${response.statusCode}");
@@ -628,16 +655,17 @@ class InvenTreeAPI {
if (response.data != null) { if (response.data != null) {
debug("Response data: ${response.data.toString()}"); debug("Response data: ${response.data.toString()}");
} }
} } else if (!data.containsKey("token")) {
// The request was otherwise successful, but the response is missing
final data = response.asMap(); // the expected token field - a distinct (and much rarer) problem from
// an authentication failure, so only reachable when that did NOT occur
if (!data.containsKey("token")) { if (showDialog) {
showServerError( showServerError(
apiUrl, apiUrl,
L10().tokenMissing, L10().tokenMissing,
L10().tokenMissingFromResponse, L10().tokenMissingFromResponse,
); );
}
} }
// Save the token to the user profile // Save the token to the user profile
@@ -945,7 +973,11 @@ class InvenTreeAPI {
}); });
} on SocketException catch (error) { } on SocketException catch (error) {
debug("SocketException at ${url}: ${error.toString()}"); debug("SocketException at ${url}: ${error.toString()}");
showServerError(url, L10().connectionRefused, error.toString()); showServerError(
url,
L10().connectionRefused,
L10().connectionRefusedDetail,
);
return; return;
} on TimeoutException { } on TimeoutException {
debug("TimeoutException at ${url}"); debug("TimeoutException at ${url}");
@@ -980,8 +1012,12 @@ class InvenTreeAPI {
} else { } else {
showStatusCodeError(url, response.statusCode); showStatusCodeError(url, response.statusCode);
} }
} on SocketException catch (error) { } on SocketException {
showServerError(url, L10().connectionRefused, error.toString()); showServerError(
url,
L10().connectionRefused,
L10().connectionRefusedDetail,
);
} on TimeoutException { } on TimeoutException {
showTimeoutError(url); showTimeoutError(url);
} catch (error, stackTrace) { } catch (error, stackTrace) {
@@ -1063,7 +1099,11 @@ class InvenTreeAPI {
); );
} }
} on SocketException catch (error) { } on SocketException catch (error) {
showServerError(url, L10().connectionRefused, error.toString()); showServerError(
url,
L10().connectionRefused,
L10().connectionRefusedDetail,
);
response.error = "SocketException"; response.error = "SocketException";
response.errorDetail = error.toString(); response.errorDetail = error.toString();
} on FormatException { } on FormatException {
@@ -1190,12 +1230,13 @@ class InvenTreeAPI {
client.badCertificateCallback = client.badCertificateCallback =
(X509Certificate cert, String host, int port) { (X509Certificate cert, String host, int port) {
// The active profile may have been explicitly trusted by the user
// (in-flow, after a prior certificate failure) - honor that first
if (profile?.trustedCertificate ?? false) {
return true;
}
if (_strictHttps) { if (_strictHttps) {
showServerError(
"${host}:${port}",
L10().serverCertificateError,
L10().serverCertificateInvalid,
);
return false; return false;
} }
@@ -1287,10 +1328,10 @@ class InvenTreeAPI {
HttpClientRequest? _request; HttpClientRequest? _request;
// Attempt to open a connection to the server // Attempt to open a connection to the server
// (retries once after a short delay on a transient network blip -
// nothing has been sent yet at this point, so a retry here is safe)
try { try {
_request = await httpClient _request = await _openUrlWithRetry(method, _uri);
.openUrl(method, _uri)
.timeout(Duration(seconds: 10));
// Default headers // Default headers
defaultHeaders().forEach((key, value) { defaultHeaders().forEach((key, value) {
@@ -1305,7 +1346,11 @@ class InvenTreeAPI {
return _request; return _request;
} on SocketException catch (error) { } on SocketException catch (error) {
debug("SocketException at ${url}: ${error.toString()}"); debug("SocketException at ${url}: ${error.toString()}");
showServerError(url, L10().connectionRefused, error.toString()); showServerError(
url,
L10().connectionRefused,
L10().connectionRefusedDetail,
);
return null; return null;
} on TimeoutException { } on TimeoutException {
debug("TimeoutException at ${url}"); debug("TimeoutException at ${url}");
@@ -1313,14 +1358,36 @@ class InvenTreeAPI {
return null; return null;
} on OSError catch (error) { } on OSError catch (error) {
debug("OSError at ${url}: ${error.toString()}"); debug("OSError at ${url}: ${error.toString()}");
showServerError(url, L10().connectionRefused, error.toString()); showServerError(
url,
L10().connectionRefused,
L10().connectionRefusedDetail,
);
return null; return null;
} on CertificateException catch (error) { } on CertificateException catch (error) {
final HttpClientRequest? retried = await _retryAfterCertificateTrust(
url,
method,
_uri,
headers,
);
if (retried != null) {
return retried;
}
debug("CertificateException at ${url}:"); debug("CertificateException at ${url}:");
debug(error.toString()); debug(error.toString());
showServerError(url, L10().serverCertificateError, error.toString()); showServerError(url, L10().serverCertificateError, error.toString());
return null; return null;
} on HandshakeException catch (error) { } on HandshakeException catch (error) {
final HttpClientRequest? retried = await _retryAfterCertificateTrust(
url,
method,
_uri,
headers,
);
if (retried != null) {
return retried;
}
debug("HandshakeException at ${url}:"); debug("HandshakeException at ${url}:");
debug(error.toString()); debug(error.toString());
showServerError(url, L10().serverCertificateError, error.toString()); showServerError(url, L10().serverCertificateError, error.toString());
@@ -1339,6 +1406,107 @@ class InvenTreeAPI {
} }
} }
/*
* Open a connection to the given URL, retrying once after a short delay
* if the first attempt fails with a transient network error. Nothing has
* been sent to the server yet at this point, so a retry here is safe
* regardless of HTTP method (unlike retrying after a response has already
* started being read/sent).
*/
Future<HttpClientRequest> _openUrlWithRetry(String method, Uri uri) async {
try {
return await httpClient
.openUrl(method, uri)
.timeout(Duration(seconds: 10));
} on SocketException {
await Future.delayed(const Duration(seconds: 2));
return await httpClient
.openUrl(method, uri)
.timeout(Duration(seconds: 10));
} on TimeoutException {
await Future.delayed(const Duration(seconds: 2));
return await httpClient
.openUrl(method, uri)
.timeout(Duration(seconds: 10));
}
}
/*
* A certificate error occurred while connecting to the active profile's
* server. If the user hasn't already trusted this profile's certificate,
* prompt them in-flow; if they accept, persist that decision against the
* profile and retry the connection once.
*/
Future<HttpClientRequest?> _retryAfterCertificateTrust(
String url,
String method,
Uri uri,
Map<String, String> headers,
) async {
final UserProfile? prf = profile;
if (prf == null || prf.trustedCertificate) {
// Nothing new to prompt for - surface the original error as-is
return null;
}
final bool trust = await _promptTrustCertificate(uri.host);
if (!trust) {
return null;
}
prf.trustedCertificate = true;
await UserProfileDBManager().updateProfile(prf);
try {
final HttpClientRequest request = await httpClient
.openUrl(method, uri)
.timeout(Duration(seconds: 10));
defaultHeaders().forEach((key, value) {
request.headers.set(key, value);
});
headers.forEach((key, value) {
request.headers.set(key, value);
});
return request;
} catch (error) {
debug(
"Retry after certificate trust failed at ${url}: ${error.toString()}",
);
return null;
}
}
/*
* Ask the user whether to trust an otherwise-invalid TLS certificate for
* the given host. Returns true if they accept.
*/
Future<bool> _promptTrustCertificate(String host) async {
final Completer<bool> completer = Completer<bool>();
confirmationDialog(
L10().serverCertificateError,
"${L10().serverCertificateTrust}\n${host}",
icon: TablerIcons.shield_exclamation,
onAccept: () {
if (!completer.isCompleted) {
completer.complete(true);
}
},
onReject: () {
if (!completer.isCompleted) {
completer.complete(false);
}
},
);
return completer.future;
}
/* /*
* Complete an API request, and return an APIResponse object * Complete an API request, and return an APIResponse object
*/ */
@@ -1414,7 +1582,11 @@ class InvenTreeAPI {
response.error = "HTTPException"; response.error = "HTTPException";
response.errorDetail = error.toString(); response.errorDetail = error.toString();
} on SocketException catch (error) { } on SocketException catch (error) {
showServerError(url, L10().connectionRefused, error.toString()); showServerError(
url,
L10().connectionRefused,
L10().connectionRefusedDetail,
);
response.error = "SocketException"; response.error = "SocketException";
response.errorDetail = error.toString(); response.errorDetail = error.toString();
} on CertificateException catch (error) { } on CertificateException catch (error) {
-1
View File
@@ -1612,7 +1612,6 @@ class APIFormWidgetState extends State<APIFormWidget> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(widget.title), title: Text(widget.title),
backgroundColor: COLOR_APP_BAR,
actions: [ actions: [
IconButton( IconButton(
icon: Icon(widget.icon), icon: Icon(widget.icon),
+19 -14
View File
@@ -17,20 +17,25 @@ bool isDarkMode() {
return AdaptiveTheme.of(context).brightness == Brightness.dark; return AdaptiveTheme.of(context).brightness == Brightness.dark;
} }
// Return an "action" color based on the current theme // Resolve the app's current ColorScheme, falling back to a sensible default
Color get COLOR_ACTION { // if no BuildContext is available yet (e.g. very early app startup).
if (isDarkMode()) { ColorScheme get _colorScheme {
return Colors.lightBlueAccent; final BuildContext? context = OneContext().context;
} else {
return Colors.blue; if (context == null) {
return const ColorScheme.light();
} }
return Theme.of(context).colorScheme;
} }
// Set to null to use the system default // Semantic colors, derived from the current theme's ColorScheme.
Color? COLOR_APP_BAR; // Material 3 has no dedicated "success"/"warning" roles, so those map onto
// the nearest available accent (tertiary / secondary respectively).
const Color COLOR_WARNING = Color.fromRGBO(250, 150, 50, 1); Color get COLOR_ACTION => Colors.blue;
const Color COLOR_DANGER = Color.fromRGBO(200, 50, 75, 1); Color get COLOR_WARNING => Colors.orange;
const Color COLOR_SUCCESS = Color.fromRGBO(100, 200, 75, 1); Color get COLOR_DANGER => _colorScheme.error;
const Color COLOR_PROGRESS = Color.fromRGBO(50, 100, 200, 1); Color get COLOR_SUCCESS => Colors.lightGreen;
const Color COLOR_GRAY_LIGHT = Color.fromRGBO(150, 150, 150, 1); Color get COLOR_PROGRESS => Colors.lightBlue;
Color get COLOR_GRAY_LIGHT => _colorScheme.onSurfaceVariant;
Color get COLOR_TEXT => _colorScheme.onSurface;
+1 -4
View File
@@ -370,10 +370,7 @@ class _CameraBarcodeControllerState extends InvenTreeBarcodeControllerState {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(L10().scanBarcode)),
backgroundColor: COLOR_APP_BAR,
title: Text(L10().scanBarcode),
),
floatingActionButton: buildActions(context), floatingActionButton: buildActions(context),
body: GestureDetector( body: GestureDetector(
onTap: () async { onTap: () async {
+1 -4
View File
@@ -94,10 +94,7 @@ class _WedgeBarcodeControllerState extends InvenTreeBarcodeControllerState {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(L10().scanBarcode)),
backgroundColor: COLOR_APP_BAR,
title: Text(L10().scanBarcode),
),
backgroundColor: Colors.black.withValues(alpha: 0.9), backgroundColor: Colors.black.withValues(alpha: 0.9),
body: Center( body: Center(
child: Column( child: Column(
+7 -6
View File
@@ -8,6 +8,7 @@ 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/api.dart"; import "package:inventree/api.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/inventree/model.dart"; import "package:inventree/inventree/model.dart";
import "package:inventree/inventree/orders.dart"; import "package:inventree/inventree/orders.dart";
import "package:inventree/inventree/part.dart"; import "package:inventree/inventree/part.dart";
@@ -380,17 +381,17 @@ class BuildOrderStatus {
static Color getStatusColor(int status) { static Color getStatusColor(int status) {
switch (status) { switch (status) {
case PENDING: case PENDING:
return Colors.blue; return COLOR_GRAY_LIGHT;
case PRODUCTION: case PRODUCTION:
return Colors.green; return COLOR_PROGRESS;
case COMPLETE: case COMPLETE:
return Colors.purple; return COLOR_SUCCESS;
case CANCELLED: case CANCELLED:
return Colors.red; return COLOR_DANGER;
case ON_HOLD: case ON_HOLD:
return Colors.orange; return COLOR_WARNING;
default: default:
return Colors.grey; return COLOR_GRAY_LIGHT;
} }
} }
+4 -4
View File
@@ -129,17 +129,17 @@ class InvenTreeStatusCode {
case "primary": case "primary":
return COLOR_PROGRESS; return COLOR_PROGRESS;
case "secondary": case "secondary":
return Colors.grey; return COLOR_GRAY_LIGHT;
case "dark": case "dark":
return Colors.black; return COLOR_TEXT;
case "danger": case "danger":
return COLOR_DANGER; return COLOR_DANGER;
case "warning": case "warning":
return COLOR_WARNING; return COLOR_WARNING;
case "info": case "info":
return Colors.lightBlue; return COLOR_PROGRESS;
default: default:
return Colors.black; return COLOR_GRAY_LIGHT;
} }
} }
} }
+18
View File
@@ -351,9 +351,15 @@
"connectionCheck": "Check Connection", "connectionCheck": "Check Connection",
"@connectionCheck": {}, "@connectionCheck": {},
"connectionCheckDetail": "Checks that the server address is reachable. This does not verify your username or password.",
"@connectionCheckDetail": {},
"connectionRefused": "Connection Refused", "connectionRefused": "Connection Refused",
"@connectionRefused": {}, "@connectionRefused": {},
"connectionRefusedDetail": "Could not reach the server. Check that the address is correct and the server is running.",
"@connectionRefusedDetail": {},
"count": "Count", "count": "Count",
"@count": { "@count": {
"description": "Count" "description": "Count"
@@ -1121,6 +1127,9 @@
"profileConnect": "Connect to Server", "profileConnect": "Connect to Server",
"@profileConnect": {}, "@profileConnect": {},
"profileSwitchConfirm": "This will disconnect your current session. Switch server profile?",
"@profileSwitchConfirm": {},
"profileEdit": "Edit Server Profile", "profileEdit": "Edit Server Profile",
"@profileEdit": {}, "@profileEdit": {},
@@ -1463,12 +1472,21 @@
"serverAuthenticationError": "Authentication Error", "serverAuthenticationError": "Authentication Error",
"@serverAuthenticationError": {}, "@serverAuthenticationError": {},
"sessionExpired": "Session Expired",
"@sessionExpired": {},
"sessionExpiredDetail": "Your session has expired. Please sign in again.",
"@sessionExpiredDetail": {},
"serverCertificateError": "Cerficate Error", "serverCertificateError": "Cerficate Error",
"@serverCertificateError": {}, "@serverCertificateError": {},
"serverCertificateInvalid": "Server HTTPS certificate is invalid", "serverCertificateInvalid": "Server HTTPS certificate is invalid",
"@serverCertificateInvalid": {}, "@serverCertificateInvalid": {},
"serverCertificateTrust": "This server's certificate could not be verified. Trust it anyway?",
"@serverCertificateTrust": {},
"serverConnected": "Connected to Server", "serverConnected": "Connected to Server",
"@serverConnected": {}, "@serverConnected": {},
+4
View File
@@ -16,6 +16,7 @@ import "package:inventree/inventree/sentry.dart";
import "package:inventree/l10n/supported_locales.dart"; import "package:inventree/l10n/supported_locales.dart";
import "package:inventree/l10n/collected/app_localizations.dart"; import "package:inventree/l10n/collected/app_localizations.dart";
import "package:inventree/settings/release.dart"; import "package:inventree/settings/release.dart";
import "package:inventree/user_profile.dart";
import "package:inventree/widget/home.dart"; import "package:inventree/widget/home.dart";
Future<void> main() async { Future<void> main() async {
@@ -123,6 +124,9 @@ class InvenTreeAppState extends State<StatefulWidget> {
Locale? locale = await InvenTreeSettingsManager().getSelectedLocale(); Locale? locale = await InvenTreeSettingsManager().getSelectedLocale();
setLocale(locale); setLocale(locale);
// First-run only: seed a demo server profile if none are configured
await UserProfileDBManager().seedDemoProfileIfNeeded();
// Display release notes if this is a new version // Display release notes if this is a new version
final String version = final String version =
await InvenTreeSettingsManager().getValue("recentVersion", "") await InvenTreeSettingsManager().getValue("recentVersion", "")
+1 -4
View File
@@ -289,10 +289,7 @@ class InvenTreeAboutWidget extends StatelessWidget {
); );
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(L10().appAbout)),
title: Text(L10().appAbout),
backgroundColor: COLOR_APP_BAR,
),
body: ListView( body: ListView(
children: ListTile.divideTiles(context: context, tiles: tiles).toList(), children: ListTile.divideTiles(context: context, tiles: tiles).toList(),
), ),
+1 -4
View File
@@ -163,10 +163,7 @@ class _InvenTreeAppSettingsState extends State<InvenTreeAppSettingsWidget> {
return Scaffold( return Scaffold(
key: _settingsKey, key: _settingsKey,
appBar: AppBar( appBar: AppBar(title: Text(L10().appSettings)),
title: Text(L10().appSettings),
backgroundColor: COLOR_APP_BAR,
),
body: Container( body: Container(
child: ListView( child: ListView(
children: [ children: [
+4 -7
View File
@@ -1,9 +1,9 @@
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/app_colors.dart";
import "package:inventree/l10.dart"; import "package:inventree/l10.dart";
import "package:inventree/preferences.dart"; import "package:inventree/preferences.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/widget/dialogs.dart"; import "package:inventree/widget/dialogs.dart";
@@ -68,7 +68,7 @@ class _InvenTreeBarcodeSettingsState
), ),
actions: <Widget>[ actions: <Widget>[
MaterialButton( MaterialButton(
color: Colors.red, color: COLOR_DANGER,
textColor: Colors.white, textColor: Colors.white,
child: Text(L10().cancel), child: Text(L10().cancel),
onPressed: () { onPressed: () {
@@ -78,7 +78,7 @@ class _InvenTreeBarcodeSettingsState
}, },
), ),
MaterialButton( MaterialButton(
color: Colors.green, color: COLOR_SUCCESS,
textColor: Colors.white, textColor: Colors.white,
child: Text(L10().ok), child: Text(L10().ok),
onPressed: () async { onPressed: () async {
@@ -120,10 +120,7 @@ class _InvenTreeBarcodeSettingsState
} }
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(L10().barcodeSettings)),
title: Text(L10().barcodeSettings),
backgroundColor: COLOR_APP_BAR,
),
body: Container( body: Container(
child: ListView( child: ListView(
children: [ children: [
+1 -5
View File
@@ -1,6 +1,5 @@
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/app_colors.dart";
import "package:inventree/l10.dart"; import "package:inventree/l10.dart";
import "package:inventree/preferences.dart"; import "package:inventree/preferences.dart";
@@ -72,10 +71,7 @@ class _HomeScreenSettingsState extends State<HomeScreenSettingsWidget> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
key: _settingsKey, key: _settingsKey,
appBar: AppBar( appBar: AppBar(title: Text(L10().homeScreen)),
title: Text(L10().homeScreen),
backgroundColor: COLOR_APP_BAR,
),
body: Container( body: Container(
child: ListView( child: ListView(
children: [ children: [
+14 -6
View File
@@ -44,19 +44,28 @@ class _InvenTreeLoginState extends State<InvenTreeLoginWidget> {
showLoadingOverlay(); showLoadingOverlay();
// Attempt login // Attempt login - suppress the generic error dialog, since this screen
// shows its own contextual inline error message below
final response = await InvenTreeAPI().fetchToken( final response = await InvenTreeAPI().fetchToken(
widget.profile, widget.profile,
username, username,
password, password,
showDialog: false,
); );
hideLoadingOverlay();
if (response.successful()) { if (response.successful()) {
// Return to the server selector screen // A token was issued - immediately connect using it, then return
Navigator.of(context).pop(); // directly to the home screen (rather than leaving the user on the
// server-selector screen to navigate back manually)
await InvenTreeAPI().connectToServer(widget.profile);
hideLoadingOverlay();
if (context.mounted) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
} else { } else {
hideLoadingOverlay();
var data = response.asMap(); var data = response.asMap();
String err; String err;
@@ -104,7 +113,6 @@ class _InvenTreeLoginState extends State<InvenTreeLoginWidget> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(L10().login), title: Text(L10().login),
backgroundColor: COLOR_APP_BAR,
actions: [ actions: [
IconButton( IconButton(
icon: Icon(TablerIcons.transition_right, color: COLOR_SUCCESS), icon: Icon(TablerIcons.transition_right, color: COLOR_SUCCESS),
+1 -5
View File
@@ -2,7 +2,6 @@ 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/l10.dart"; import "package:inventree/l10.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/preferences.dart"; import "package:inventree/preferences.dart";
class InvenTreePartSettingsWidget extends StatefulWidget { class InvenTreePartSettingsWidget extends StatefulWidget {
@@ -46,10 +45,7 @@ class _InvenTreePartSettingsState extends State<InvenTreePartSettingsWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(L10().partSettings)),
title: Text(L10().partSettings),
backgroundColor: COLOR_APP_BAR,
),
body: Container( body: Container(
child: ListView( child: ListView(
children: [ children: [
+1 -5
View File
@@ -1,6 +1,5 @@
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/app_colors.dart";
import "package:inventree/l10.dart"; import "package:inventree/l10.dart";
import "package:inventree/preferences.dart"; import "package:inventree/preferences.dart";
@@ -45,10 +44,7 @@ class _InvenTreePurchaseOrderSettingsState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(L10().purchaseOrderSettings)),
title: Text(L10().purchaseOrderSettings),
backgroundColor: COLOR_APP_BAR,
),
body: Container( body: Container(
child: ListView( child: ListView(
children: [ children: [
+2 -9
View File
@@ -1,6 +1,5 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_markdown/flutter_markdown.dart"; import "package:flutter_markdown/flutter_markdown.dart";
import "package:inventree/app_colors.dart";
import "package:url_launcher/url_launcher.dart"; import "package:url_launcher/url_launcher.dart";
import "package:inventree/l10.dart"; import "package:inventree/l10.dart";
@@ -14,10 +13,7 @@ class ReleaseNotesWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(L10().releaseNotes)),
title: Text(L10().releaseNotes),
backgroundColor: COLOR_APP_BAR,
),
body: Markdown( body: Markdown(
selectable: false, selectable: false,
data: releaseNotes, data: releaseNotes,
@@ -49,10 +45,7 @@ class CreditsWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(L10().credits)),
title: Text(L10().credits),
backgroundColor: COLOR_APP_BAR,
),
body: Markdown( body: Markdown(
selectable: false, selectable: false,
data: credits, data: credits,
+1 -5
View File
@@ -2,7 +2,6 @@ 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/l10.dart"; import "package:inventree/l10.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/preferences.dart"; import "package:inventree/preferences.dart";
class InvenTreeSalesOrderSettingsWidget extends StatefulWidget { class InvenTreeSalesOrderSettingsWidget extends StatefulWidget {
@@ -40,10 +39,7 @@ class _InvenTreeSalesOrderSettingsState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(L10().salesOrderSettings)),
title: Text(L10().salesOrderSettings),
backgroundColor: COLOR_APP_BAR,
),
body: Container( body: Container(
child: ListView( child: ListView(
children: [ children: [
+130 -87
View File
@@ -6,7 +6,6 @@ import "package:inventree/settings/login.dart";
import "package:inventree/app_colors.dart"; import "package:inventree/app_colors.dart";
import "package:inventree/widget/dialogs.dart"; import "package:inventree/widget/dialogs.dart";
import "package:inventree/widget/spinner.dart";
import "package:inventree/l10.dart"; import "package:inventree/l10.dart";
import "package:inventree/api.dart"; import "package:inventree/api.dart";
import "package:inventree/user_profile.dart"; import "package:inventree/user_profile.dart";
@@ -70,7 +69,34 @@ class _InvenTreeSelectServerState extends State<InvenTreeSelectServerWidget> {
}); });
} }
/*
* Select the given profile as "active".
* If a *different* server is currently connected, confirm with the
* user first, since this immediately tears down the active session.
*/
Future<void> _selectProfile(BuildContext context, UserProfile profile) async { Future<void> _selectProfile(BuildContext context, UserProfile profile) async {
final bool switchingAwayFromActiveSession =
InvenTreeAPI().isConnected() &&
InvenTreeAPI().profile?.key != profile.key;
if (switchingAwayFromActiveSession) {
confirmationDialog(
L10().profileConnect,
L10().profileSwitchConfirm,
icon: TablerIcons.server,
onAccept: () {
_doSelectProfile(context, profile);
},
);
} else {
_doSelectProfile(context, profile);
}
}
Future<void> _doSelectProfile(
BuildContext context,
UserProfile profile,
) async {
// Disconnect InvenTree // Disconnect InvenTree
InvenTreeAPI().disconnectFromServer(); InvenTreeAPI().disconnectFromServer();
@@ -90,22 +116,13 @@ class _InvenTreeSelectServerState extends State<InvenTreeSelectServerWidget> {
// First check if the profile has an associate token // First check if the profile has an associate token
if (!prf.hasToken) { if (!prf.hasToken) {
// Redirect user to login screen // Redirect user to the login screen - it connects on success itself
Navigator.push( await Navigator.push(
context, context,
MaterialPageRoute(builder: (context) => InvenTreeLoginWidget(profile)), MaterialPageRoute(builder: (context) => InvenTreeLoginWidget(profile)),
).then((value) async { );
_reload();
// Reload profile
prf = await UserProfileDBManager().getProfileByKey(key);
if (prf?.hasToken ?? false) {
InvenTreeAPI().connectToServer(prf!).then((result) {
_reload();
});
}
});
// Exit now, login handled by next widget _reload();
return; return;
} }
@@ -113,12 +130,8 @@ class _InvenTreeSelectServerState extends State<InvenTreeSelectServerWidget> {
return; return;
} }
_reload(); // Attempt server connection using the existing token
await InvenTreeAPI().connectToServer(prf);
// Attempt server login (this will load the newly selected profile
InvenTreeAPI().connectToServer(prf).then((result) {
_reload();
});
_reload(); _reload();
} }
@@ -151,12 +164,86 @@ class _InvenTreeSelectServerState extends State<InvenTreeSelectServerWidget> {
if (InvenTreeAPI().isConnected()) { if (InvenTreeAPI().isConnected()) {
return Icon(TablerIcons.circle_check, color: COLOR_SUCCESS); return Icon(TablerIcons.circle_check, color: COLOR_SUCCESS);
} else if (InvenTreeAPI().isConnecting()) { } else if (InvenTreeAPI().isConnecting()) {
return Spinner(icon: TablerIcons.loader_2, color: COLOR_PROGRESS); return SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(color: COLOR_PROGRESS, strokeWidth: 2),
);
} else { } else {
return Icon(TablerIcons.circle_x, color: COLOR_DANGER); return Icon(TablerIcons.circle_x, color: COLOR_DANGER);
} }
} }
/*
* Show the profile action menu (Connect / Edit / Logout / Delete).
* Reachable via the always-visible trailing "more" button, or long-press
* as a bonus shortcut.
*/
void _showProfileActions(BuildContext context, UserProfile profile) {
OneContext().showDialog(
builder: (BuildContext context) {
return SimpleDialog(
title: Text(profile.name),
children: <Widget>[
Divider(),
SimpleDialogOption(
onPressed: () {
Navigator.of(context).pop();
_selectProfile(context, profile);
},
child: ListTile(
title: Text(L10().profileConnect),
leading: Icon(TablerIcons.server),
),
),
SimpleDialogOption(
onPressed: () {
Navigator.of(context).pop();
_editProfile(context, userProfile: profile);
},
child: ListTile(
title: Text(L10().profileEdit),
leading: Icon(TablerIcons.edit),
),
),
SimpleDialogOption(
onPressed: () {
Navigator.of(context).pop();
_logoutProfile(context, userProfile: profile);
},
child: ListTile(
title: Text(L10().profileLogout),
leading: Icon(TablerIcons.logout),
),
),
Divider(),
SimpleDialogOption(
onPressed: () {
Navigator.of(context).pop();
confirmationDialog(
L10().delete,
L10().profileDelete + "?",
color: COLOR_DANGER,
icon: TablerIcons.trash,
onAccept: () {
_deleteProfile(profile);
},
);
},
child: ListTile(
title: Text(
L10().profileDelete,
style: TextStyle(color: COLOR_DANGER),
),
leading: Icon(TablerIcons.trash, color: COLOR_DANGER),
),
),
],
);
},
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
List<Widget> children = []; List<Widget> children = [];
@@ -175,74 +262,24 @@ class _InvenTreeSelectServerState extends State<InvenTreeSelectServerWidget> {
leading: profile.hasToken leading: profile.hasToken
? Icon(TablerIcons.user_check, color: COLOR_SUCCESS) ? Icon(TablerIcons.user_check, color: COLOR_SUCCESS)
: Icon(TablerIcons.user_cancel, color: COLOR_WARNING), : Icon(TablerIcons.user_cancel, color: COLOR_WARNING),
trailing: _getProfileIcon(profile), trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_getProfileIcon(profile) != null) _getProfileIcon(profile)!,
IconButton(
icon: Icon(Icons.more_vert),
tooltip: L10().actions,
onPressed: () {
_showProfileActions(context, profile);
},
),
],
),
onTap: () { onTap: () {
_selectProfile(context, profile); _selectProfile(context, profile);
}, },
onLongPress: () { onLongPress: () {
OneContext().showDialog( _showProfileActions(context, profile);
builder: (BuildContext context) {
return SimpleDialog(
title: Text(profile.name),
children: <Widget>[
Divider(),
SimpleDialogOption(
onPressed: () {
Navigator.of(context).pop();
_selectProfile(context, profile);
},
child: ListTile(
title: Text(L10().profileConnect),
leading: Icon(TablerIcons.server),
),
),
SimpleDialogOption(
onPressed: () {
Navigator.of(context).pop();
_editProfile(context, userProfile: profile);
},
child: ListTile(
title: Text(L10().profileEdit),
leading: Icon(TablerIcons.edit),
),
),
SimpleDialogOption(
onPressed: () {
Navigator.of(context).pop();
_logoutProfile(context, userProfile: profile);
},
child: ListTile(
title: Text(L10().profileLogout),
leading: Icon(TablerIcons.logout),
),
),
Divider(),
SimpleDialogOption(
onPressed: () {
Navigator.of(context).pop();
// Navigator.of(context, rootNavigator: true).pop();
confirmationDialog(
L10().delete,
L10().profileDelete + "?",
color: Colors.red,
icon: TablerIcons.trash,
onAccept: () {
_deleteProfile(profile);
},
);
},
child: ListTile(
title: Text(
L10().profileDelete,
style: TextStyle(color: Colors.red),
),
leading: Icon(TablerIcons.trash, color: Colors.red),
),
),
],
);
},
);
}, },
), ),
); );
@@ -256,7 +293,6 @@ class _InvenTreeSelectServerState extends State<InvenTreeSelectServerWidget> {
key: _loginKey, key: _loginKey,
appBar: AppBar( appBar: AppBar(
title: Text(L10().profileSelect), title: Text(L10().profileSelect),
backgroundColor: COLOR_APP_BAR,
actions: [ actions: [
IconButton( IconButton(
icon: Icon(TablerIcons.circle_plus), icon: Icon(TablerIcons.circle_plus),
@@ -305,7 +341,6 @@ class _ProfileEditState extends State<ProfileEditWidget> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
backgroundColor: COLOR_APP_BAR,
title: Text( title: Text(
widget.profile == null ? L10().profileAdd : L10().profileEdit, widget.profile == null ? L10().profileAdd : L10().profileEdit,
), ),
@@ -415,6 +450,14 @@ class _ProfileEditState extends State<ProfileEditWidget> {
}, },
), ),
Divider(), Divider(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
L10().connectionCheckDetail,
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 8),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton.icon( child: ElevatedButton.icon(
+1 -4
View File
@@ -42,10 +42,7 @@ class _InvenTreeSettingsState extends State<InvenTreeSettingsWidget> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
key: _scaffoldKey, key: _scaffoldKey,
appBar: AppBar( appBar: AppBar(title: Text(L10().settings)),
title: Text(L10().settings),
backgroundColor: COLOR_APP_BAR,
),
body: Center( body: Center(
child: ListView( child: ListView(
children: [ children: [
+1 -5
View File
@@ -1,6 +1,5 @@
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/app_colors.dart";
import "package:inventree/l10.dart"; import "package:inventree/l10.dart";
import "package:inventree/preferences.dart"; import "package:inventree/preferences.dart";
@@ -44,10 +43,7 @@ class _InvenTreeStockSettingsState extends State<InvenTreeStockSettingsWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(L10().stockSettings)),
title: Text(L10().stockSettings),
backgroundColor: COLOR_APP_BAR,
),
body: Container( body: Container(
child: ListView( child: ListView(
children: [ children: [
+107 -30
View File
@@ -1,3 +1,4 @@
import "package:flutter_secure_storage/flutter_secure_storage.dart";
import "package:sembast/sembast.dart"; import "package:sembast/sembast.dart";
import "package:inventree/helpers.dart"; import "package:inventree/helpers.dart";
@@ -10,6 +11,7 @@ class UserProfile {
this.server = "", this.server = "",
this.token = "", this.token = "",
this.selected = false, this.selected = false,
this.trustedCertificate = false,
}); });
factory UserProfile.fromJson( factory UserProfile.fromJson(
@@ -20,8 +22,11 @@ class UserProfile {
key: key, key: key,
name: (json["name"] ?? "") as String, name: (json["name"] ?? "") as String,
server: (json["server"] ?? "") as String, server: (json["server"] ?? "") as String,
// Legacy field - profiles saved before the token moved to secure storage
// may still have it here. UserProfileDBManager migrates this on read.
token: (json["token"] ?? "") as String, token: (json["token"] ?? "") as String,
selected: isSelected, selected: isSelected,
trustedCertificate: (json["trustedCertificate"] ?? false) as bool,
); );
// Return true if this profile has a token // Return true if this profile has a token
@@ -36,18 +41,23 @@ class UserProfile {
// Base address of the InvenTree server // Base address of the InvenTree server
String server = ""; String server = "";
// API token // API token - held in memory only; persisted separately in secure storage
// (see UserProfileDBManager), never written to the plain profile record
String token = ""; String token = "";
bool selected = false; bool selected = false;
// Whether the user has explicitly chosen to trust this server's TLS
// certificate despite it failing validation (e.g. self-signed)
bool trustedCertificate = false;
// User ID (will be provided by the server on log-in) // User ID (will be provided by the server on log-in)
int user_id = -1; int user_id = -1;
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
"name": name, "name": name,
"server": server, "server": server,
"token": token, "trustedCertificate": trustedCertificate,
}; };
@override @override
@@ -62,8 +72,54 @@ class UserProfile {
class UserProfileDBManager { class UserProfileDBManager {
final store = StoreRef("profiles"); final store = StoreRef("profiles");
static const _secureStorage = FlutterSecureStorage();
Future<Database> get _db async => InvenTreePreferencesDB.instance.database; Future<Database> get _db async => InvenTreePreferencesDB.instance.database;
// Key used to store a profile's API token in secure storage
String _tokenStorageKey(int key) => "profile_token_${key}";
/*
* Persist (or clear) a profile's token in secure storage, keyed by its
* database record id. The token never lives in the plain Sembast record.
*/
Future<void> _saveToken(int key, String token) async {
if (token.isEmpty) {
await _secureStorage.delete(key: _tokenStorageKey(key));
} else {
await _secureStorage.write(key: _tokenStorageKey(key), value: token);
}
}
/*
* Populate a profile's in-memory token from secure storage.
* If secure storage has nothing for this profile, but the (legacy)
* Sembast record still has a plaintext token from before this migration,
* adopt it once and move it into secure storage.
*/
Future<UserProfile> _loadToken(UserProfile profile) async {
final int? key = profile.key;
if (key == null) {
return profile;
}
final String? secureToken = await _secureStorage.read(
key: _tokenStorageKey(key),
);
if (secureToken != null) {
profile.token = secureToken;
} else if (profile.token.isNotEmpty) {
// Legacy profile - migrate its plaintext token into secure storage,
// and strip it from the Sembast record on next write
await _saveToken(key, profile.token);
await store.record(key).update(await _db, profile.toJson());
}
return profile;
}
/* /*
* Check if a profile with the specified name exists in the database * Check if a profile with the specified name exists in the database
*/ */
@@ -106,6 +162,10 @@ class UserProfileDBManager {
// Record the key // Record the key
profile.key = key; profile.key = key;
if (key != null) {
await _saveToken(key, profile.token);
}
return true; return true;
} }
@@ -126,6 +186,7 @@ class UserProfileDBManager {
} }
await store.record(profile.key).update(await _db, profile.toJson()); await store.record(profile.key).update(await _db, profile.toJson());
await _saveToken(profile.key!, profile.token);
return true; return true;
} }
@@ -137,6 +198,10 @@ class UserProfileDBManager {
debug("deleteProfile: ${profile.name}"); debug("deleteProfile: ${profile.name}");
await store.record(profile.key).delete(await _db); await store.record(profile.key).delete(await _db);
if (profile.key != null) {
await _secureStorage.delete(key: _tokenStorageKey(profile.key!));
}
} }
/* /*
@@ -154,11 +219,13 @@ class UserProfileDBManager {
for (int idx = 0; idx < profiles.length; idx++) { for (int idx = 0; idx < profiles.length; idx++) {
if (profiles[idx].key is int && profiles[idx].key == selected) { if (profiles[idx].key is int && profiles[idx].key == selected) {
return UserProfile.fromJson( final profile = UserProfile.fromJson(
profiles[idx].key! as int, profiles[idx].key! as int,
profiles[idx].value! as Map<String, dynamic>, profiles[idx].value! as Map<String, dynamic>,
profiles[idx].key == selected, profiles[idx].key == selected,
); );
return _loadToken(profile);
} }
} }
@@ -177,41 +244,51 @@ class UserProfileDBManager {
for (int idx = 0; idx < profiles.length; idx++) { for (int idx = 0; idx < profiles.length; idx++) {
if (profiles[idx].key is int) { if (profiles[idx].key is int) {
profileList.add( final profile = UserProfile.fromJson(
UserProfile.fromJson( profiles[idx].key! as int,
profiles[idx].key! as int, profiles[idx].value! as Map<String, dynamic>,
profiles[idx].value! as Map<String, dynamic>, profiles[idx].key == selected,
profiles[idx].key == selected,
),
);
}
}
// If there are no available profiles, create a demo profile
if (profileList.isEmpty) {
bool added = await InvenTreeSettingsManager().getBool(
"demo_profile_added",
false,
);
// Don't add a new profile if we have added it previously
if (!added) {
await InvenTreeSettingsManager().setValue("demo_profile_added", true);
UserProfile demoProfile = UserProfile(
name: "InvenTree Demo",
server: "https://demo.inventree.org",
); );
await addProfile(demoProfile); profileList.add(await _loadToken(profile));
profileList.add(demoProfile);
} }
} }
return profileList; return profileList;
} }
/*
* Seed a demo server profile, if this is the first time the app has been
* run with zero profiles configured. Explicit (called once from app
* startup) rather than a side effect of reading the profile list.
*/
Future<void> seedDemoProfileIfNeeded() async {
final profiles = await getAllProfiles();
if (profiles.isNotEmpty) {
return;
}
bool added = await InvenTreeSettingsManager().getBool(
"demo_profile_added",
false,
);
// Don't add a new profile if we have added it previously
if (added) {
return;
}
await InvenTreeSettingsManager().setValue("demo_profile_added", true);
UserProfile demoProfile = UserProfile(
name: "InvenTree Demo",
server: "https://demo.inventree.org",
);
await addProfile(demoProfile);
}
/* /*
* Retrieve a profile by key (or null if no match exists) * Retrieve a profile by key (or null if no match exists)
*/ */
+21 -19
View File
@@ -83,7 +83,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
if (showCameraShortcut && widget.order.canEdit) { if (showCameraShortcut && widget.order.canEdit) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: const Icon(TablerIcons.camera, color: Colors.blue), child: Icon(TablerIcons.camera, color: COLOR_ACTION),
label: L10().takePicture, label: L10().takePicture,
onTap: () async { onTap: () async {
_uploadImage(context); _uploadImage(context);
@@ -98,7 +98,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
if (widget.order.canIssue) { if (widget.order.canIssue) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: const Icon(TablerIcons.send, color: Colors.blue), child: Icon(TablerIcons.send, color: COLOR_ACTION),
label: L10().issueOrder, label: L10().issueOrder,
onTap: () async { onTap: () async {
_issueOrder(context); _issueOrder(context);
@@ -111,7 +111,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
if (widget.order.canCompleteOrder) { if (widget.order.canCompleteOrder) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: const Icon(TablerIcons.check, color: Colors.green), child: Icon(TablerIcons.check, color: COLOR_SUCCESS),
label: L10().completeOrder, label: L10().completeOrder,
onTap: () async { onTap: () async {
_completeOrder(context); _completeOrder(context);
@@ -124,7 +124,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
if (widget.order.canHold) { if (widget.order.canHold) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: const Icon(TablerIcons.player_pause, color: Colors.orange), child: Icon(TablerIcons.player_pause, color: COLOR_WARNING),
label: L10().holdOrder, label: L10().holdOrder,
onTap: () async { onTap: () async {
_holdOrder(context); _holdOrder(context);
@@ -137,9 +137,9 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
if (widget.order.isInProgress) { if (widget.order.isInProgress) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: const Icon( child: Icon(
TablerIcons.arrow_autofit_down, TablerIcons.arrow_autofit_down,
color: Colors.purple, color: Theme.of(context).colorScheme.secondary,
), ),
label: L10().allocateAuto, label: L10().allocateAuto,
onTap: () async { onTap: () async {
@@ -154,7 +154,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
widget.order.allocatedLineItemCount > 0) { widget.order.allocatedLineItemCount > 0) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: const Icon(TablerIcons.arrow_autofit_up, color: Colors.red), child: Icon(TablerIcons.arrow_autofit_up, color: COLOR_DANGER),
label: L10().unallocateStock, label: L10().unallocateStock,
onTap: () async { onTap: () async {
_unallocateAll(context); _unallocateAll(context);
@@ -167,7 +167,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
if (widget.order.canCancel) { if (widget.order.canCancel) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: const Icon(TablerIcons.circle_x, color: Colors.red), child: Icon(TablerIcons.circle_x, color: COLOR_DANGER),
label: L10().cancelOrder, label: L10().cancelOrder,
onTap: () async { onTap: () async {
_cancelOrder(context); _cancelOrder(context);
@@ -192,7 +192,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
L10().issueOrder, L10().issueOrder,
L10().issueOrderConfirm, L10().issueOrderConfirm,
icon: TablerIcons.send, icon: TablerIcons.send,
color: Colors.blue, color: COLOR_ACTION,
acceptText: L10().issue, acceptText: L10().issue,
onAccept: () async { onAccept: () async {
widget.order.issue().then((dynamic) { widget.order.issue().then((dynamic) {
@@ -208,7 +208,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
L10().completeOrder, L10().completeOrder,
L10().completeOrderConfirm, L10().completeOrderConfirm,
icon: TablerIcons.check, icon: TablerIcons.check,
color: Colors.green, color: COLOR_SUCCESS,
acceptText: L10().complete, acceptText: L10().complete,
onAccept: () async { onAccept: () async {
widget.order.completeOrder().then((dynamic) { widget.order.completeOrder().then((dynamic) {
@@ -224,7 +224,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
L10().holdOrder, L10().holdOrder,
L10().holdOrderConfirm, L10().holdOrderConfirm,
icon: TablerIcons.player_pause, icon: TablerIcons.player_pause,
color: Colors.orange, color: COLOR_WARNING,
acceptText: L10().hold, acceptText: L10().hold,
onAccept: () async { onAccept: () async {
widget.order.hold().then((dynamic) { widget.order.hold().then((dynamic) {
@@ -240,7 +240,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
L10().cancelOrder, L10().cancelOrder,
L10().cancelOrderConfirm, L10().cancelOrderConfirm,
icon: TablerIcons.circle_x, icon: TablerIcons.circle_x,
color: Colors.red, color: COLOR_DANGER,
acceptText: L10().cancel, acceptText: L10().cancel,
onAccept: () async { onAccept: () async {
widget.order.cancel().then((dynamic) { widget.order.cancel().then((dynamic) {
@@ -256,7 +256,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
L10().allocateAuto, L10().allocateAuto,
L10().allocateAutoDetail, L10().allocateAutoDetail,
icon: TablerIcons.arrow_autofit_down, icon: TablerIcons.arrow_autofit_down,
color: Colors.purple, color: Theme.of(context).colorScheme.secondary,
acceptText: L10().allocate, acceptText: L10().allocate,
onAccept: () async { onAccept: () async {
widget.order.autoAllocate().then((dynamic) { widget.order.autoAllocate().then((dynamic) {
@@ -272,7 +272,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
L10().unallocateStock, L10().unallocateStock,
L10().buildOrderUnallocateDetail, L10().buildOrderUnallocateDetail,
icon: TablerIcons.trash, icon: TablerIcons.trash,
color: Colors.orange, color: COLOR_WARNING,
acceptText: L10().unallocate, acceptText: L10().unallocate,
onAccept: () async { onAccept: () async {
widget.order.unallocateAll().then((dynamic) { widget.order.unallocateAll().then((dynamic) {
@@ -399,7 +399,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
height: 32, height: 32,
child: InvenTreeAPI().getThumbnail(part.thumbnail), child: InvenTreeAPI().getThumbnail(part.thumbnail),
) )
: const Icon(TablerIcons.box, color: Colors.blue), : Icon(TablerIcons.box, color: COLOR_ACTION),
onTap: () { onTap: () {
part.goToDetailPage(context); part.goToDetailPage(context);
}, },
@@ -419,14 +419,14 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
); );
// Progress bar // Progress bar
Color progressColor = Colors.blue; Color progressColor = COLOR_PROGRESS;
if (widget.order.isComplete) { if (widget.order.isComplete) {
progressColor = Colors.green; progressColor = COLOR_SUCCESS;
} else if (widget.order.targetDate.isNotEmpty && } else if (widget.order.targetDate.isNotEmpty &&
DateTime.tryParse(widget.order.targetDate) != null && DateTime.tryParse(widget.order.targetDate) != null &&
DateTime.tryParse(widget.order.targetDate)!.isBefore(DateTime.now())) { DateTime.tryParse(widget.order.targetDate)!.isBefore(DateTime.now())) {
progressColor = Colors.red; progressColor = COLOR_DANGER;
} }
tiles.add( tiles.add(
@@ -434,7 +434,9 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
title: LinearProgressIndicator( title: LinearProgressIndicator(
value: widget.order.progressPercent / 100.0, value: widget.order.progressPercent / 100.0,
color: progressColor, color: progressColor,
backgroundColor: const Color(0xFFEEEEEE), backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
), ),
leading: const Icon(TablerIcons.chart_bar), leading: const Icon(TablerIcons.chart_bar),
trailing: Text( trailing: Text(
+2 -2
View File
@@ -60,7 +60,7 @@ class _BuildItemDetailWidgetState
// Unallocate button // Unallocate button
buttons.add( buttons.add(
SpeedDialChild( SpeedDialChild(
child: const Icon(TablerIcons.minus, color: Colors.red), child: Icon(TablerIcons.minus, color: COLOR_DANGER),
label: L10().unallocate, label: L10().unallocate,
onTap: () async { onTap: () async {
_unallocateStock(context); _unallocateStock(context);
@@ -99,7 +99,7 @@ class _BuildItemDetailWidgetState
L10().unallocateStock, L10().unallocateStock,
L10().unallocateStockConfirm, L10().unallocateStockConfirm,
icon: TablerIcons.minus, icon: TablerIcons.minus,
color: Colors.red, color: COLOR_DANGER,
acceptText: L10().unallocate, acceptText: L10().unallocate,
onAccept: () async { onAccept: () async {
widget.item.delete().then((result) { widget.item.delete().then((result) {
+2 -1
View File
@@ -3,6 +3,7 @@ import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/widget/progress.dart"; import "package:inventree/widget/progress.dart";
import "package:inventree/api.dart"; import "package:inventree/api.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/l10.dart"; import "package:inventree/l10.dart";
import "package:inventree/inventree/build.dart"; import "package:inventree/inventree/build.dart";
@@ -149,7 +150,7 @@ class BuildOrderListItem extends StatelessWidget {
DateTime.tryParse( DateTime.tryParse(
order.targetDate, order.targetDate,
)!.isBefore(DateTime.now())) )!.isBefore(DateTime.now()))
? Colors.red ? COLOR_DANGER
: null, : null,
), ),
), ),
+2 -1
View File
@@ -3,6 +3,7 @@ import "package:flutter_speed_dial/flutter_speed_dial.dart";
import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/api.dart"; import "package:inventree/api.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/l10.dart"; import "package:inventree/l10.dart";
import "package:inventree/inventree/company.dart"; import "package:inventree/inventree/company.dart";
@@ -55,7 +56,7 @@ class _CompanyListWidgetState extends RefreshableState<CompanyListWidget> {
if (InvenTreeAPI().checkPermission("company", "add")) { if (InvenTreeAPI().checkPermission("company", "add")) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.circle_plus, color: Colors.green), child: Icon(TablerIcons.circle_plus, color: COLOR_SUCCESS),
label: L10().companyAdd, label: L10().companyAdd,
onTap: () { onTap: () {
_addCompany(context); _addCompany(context);
+130 -73
View File
@@ -3,11 +3,11 @@ import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:one_context/one_context.dart"; import "package:one_context/one_context.dart";
import "package:inventree/api.dart"; import "package:inventree/api.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/helpers.dart"; import "package:inventree/helpers.dart";
import "package:inventree/l10.dart"; import "package:inventree/l10.dart";
import "package:inventree/preferences.dart"; import "package:inventree/preferences.dart";
import "package:inventree/widget/snacks.dart";
/* /*
* Launch a dialog allowing the user to select from a list of options * Launch a dialog allowing the user to select from a list of options
@@ -113,85 +113,153 @@ Future<void> confirmationDialog(
); );
} }
/*
* Convert a raw API field name (snake_case) into a human-readable label,
* e.g. "target_date" -> "Target Date"
*/
String _humanizeFieldName(String field) {
return field
.split("_")
.where((word) => word.isNotEmpty)
.map((word) => word[0].toUpperCase() + word.substring(1))
.join(" ");
}
/*
* Build the body content for an error dialog, given either a plain
* description string or a structured APIResponse.
*/
Widget _buildErrorContent(String description, APIResponse? response) {
List<Widget> children = [];
if (description.isNotEmpty) {
children.add(Text(description));
} else if (response != null) {
final Map<String, dynamic> data = response.isMap() ? response.asMap() : {};
if (data["detail"] is String) {
// Standard DRF shape for auth / permission / not-found / throttle errors
children.add(Text(data["detail"] as String));
} else {
// Non-field errors (form-level validation failures)
List<String> nonFieldErrors = [];
for (String key in ["detail", "non_field_errors", "__all__", "errors"]) {
dynamic value = data[key];
if (value is String) {
nonFieldErrors.add(value);
} else if (value is List) {
nonFieldErrors.addAll(value.map((e) => e.toString()));
}
}
for (String error in nonFieldErrors) {
children.add(
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(error),
),
);
}
// Per-field validation errors (typically a 400 response)
if (response.statusCode == 400 && response.data is Map<String, dynamic>) {
for (String field in data.keys) {
if ([
"detail",
"non_field_errors",
"__all__",
"errors",
].contains(field)) {
continue;
}
dynamic error = data[field];
List<String> messages = error is List
? error.map((e) => e.toString()).toList()
: [error.toString()];
children.add(
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_humanizeFieldName(field),
style: const TextStyle(fontWeight: FontWeight.bold),
),
for (String message in messages) Text(message),
],
),
),
);
}
}
// Nothing recognized above - fall back to a labeled raw diagnostic dump
if (children.isEmpty) {
children.add(Text(statusCodeToString(response.statusCode)));
children.add(const SizedBox(height: 8));
children.add(
Text(
L10().responseData,
style: const TextStyle(fontWeight: FontWeight.bold),
),
);
children.add(
Text(
response.data.toString(),
style: const TextStyle(fontFamily: "monospace", fontSize: 12),
),
);
}
}
}
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: SingleChildScrollView(
child: Column(mainAxisSize: MainAxisSize.min, children: children),
),
);
}
/* /*
* Construct an error dialog showing information to the user * Construct an error dialog showing information to the user
* *
* @title = Title to be displayed at the top of the dialog * @title = Title to be displayed at the top of the dialog
* @description = Simple string description of error * @description = Simple string description of error
* @data = Error response (e.g from server) * @response = Error response (e.g from server)
*/ */
Future<void> showErrorDialog( Future<void> showErrorDialog(
String title, { String title, {
String description = "", String description = "",
APIResponse? response, APIResponse? response,
IconData icon = TablerIcons.exclamation_circle, IconData icon = TablerIcons.exclamation_circle,
Color? color,
Function? onDismissed, Function? onDismissed,
}) async { }) async {
List<Widget> children = [];
if (description.isNotEmpty) {
children.add(ListTile(title: Text(description)));
} else if (response != null) {
// Look for extra error information in the provided APIResponse object
switch (response.statusCode) {
case 400: // Bad request (typically bad input)
if (response.data is Map<String, dynamic>) {
for (String field in response.asMap().keys) {
dynamic error = response.data[field];
if (error is List) {
for (int ii = 0; ii < error.length; ii++) {
children.add(
ListTile(
title: Text(field),
subtitle: Text(error[ii].toString()),
),
);
}
} else {
children.add(
ListTile(
title: Text(field),
subtitle: Text(response.data[field].toString()),
),
);
}
}
} else {
children.add(
ListTile(
title: Text(L10().responseInvalid),
subtitle: Text(response.data.toString()),
),
);
}
default:
// Unhandled server response
children.add(
ListTile(
title: Text(L10().statusCode),
subtitle: Text(response.statusCode.toString()),
),
);
children.add(
ListTile(
title: Text(L10().responseData),
subtitle: Text(response.data.toString()),
),
);
}
}
if (!hasContext()) { if (!hasContext()) {
return; return;
} }
final Color dialogColor = color ?? COLOR_DANGER;
OneContext() OneContext()
.showDialog( .showDialog(
builder: (context) => SimpleDialog( builder: (context) => AlertDialog(
title: ListTile(title: Text(title), leading: Icon(icon)), icon: Icon(icon, color: dialogColor),
children: children, iconColor: dialogColor,
title: Text(title),
content: _buildErrorContent(description, response),
actions: [
TextButton(
child: Text(L10().ok),
onPressed: () {
Navigator.pop(context);
},
),
],
), ),
) )
.then((value) { .then((value) {
@@ -233,18 +301,7 @@ Future<void> showServerError(
description += "\nURL: $url"; description += "\nURL: $url";
showSnackIcon( showErrorDialog(title, description: description, icon: TablerIcons.server);
title,
success: false,
actionText: L10().details,
onAction: () {
showErrorDialog(
title,
description: description,
icon: TablerIcons.server,
);
},
);
} }
/* /*
+275 -42
View File
@@ -6,6 +6,7 @@ import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/api.dart"; import "package:inventree/api.dart";
import "package:inventree/app_colors.dart"; import "package:inventree/app_colors.dart";
import "package:inventree/inventree/build.dart";
import "package:inventree/inventree/part.dart"; import "package:inventree/inventree/part.dart";
import "package:inventree/inventree/update_check.dart"; import "package:inventree/inventree/update_check.dart";
import "package:inventree/inventree/purchase_order.dart"; import "package:inventree/inventree/purchase_order.dart";
@@ -26,9 +27,96 @@ import "package:inventree/widget/order/sales_order_list.dart";
import "package:inventree/widget/build/build_list.dart"; import "package:inventree/widget/build/build_list.dart";
import "package:inventree/widget/refreshable_state.dart"; import "package:inventree/widget/refreshable_state.dart";
import "package:inventree/widget/snacks.dart"; import "package:inventree/widget/snacks.dart";
import "package:inventree/widget/spinner.dart";
import "package:inventree/widget/company/company_list.dart"; import "package:inventree/widget/company/company_list.dart";
/*
* Build a small count badge with the given background/foreground colors,
* or null if there is nothing to show.
* Extracted as a top-level function (rather than a private State method) so it
* can be unit tested in isolation, without needing a live API connection.
*/
Widget? _buildCountBadge(
int? count, {
required IconData icon,
required Color background,
required Color foreground,
}) {
if (count == null || count <= 0) {
return null;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: foreground),
const SizedBox(width: 4),
Text(count.toString(), style: TextStyle(color: foreground)),
],
),
);
}
// "Overdue" is the urgent case - use the theme's error colors
Widget? buildOverdueBadge(BuildContext context, int? count) {
final ColorScheme colors = Theme.of(context).colorScheme;
return _buildCountBadge(
count,
icon: TablerIcons.calendar_exclamation,
background: colors.errorContainer,
foreground: colors.onErrorContainer,
);
}
// "Outstanding" is informational rather than urgent - use a less serious color
Widget? buildOutstandingBadge(BuildContext context, int? count) {
final ColorScheme colors = Theme.of(context).colorScheme;
return _buildCountBadge(
count,
icon: TablerIcons.progress,
background: colors.secondaryContainer,
foreground: colors.onSecondaryContainer,
);
}
/*
* Combine the "outstanding" and "overdue" badges for a single tile into one
* widget (side by side), or null if neither has anything to show.
*/
Widget? buildOrderBadges(
BuildContext context, {
int? outstandingCount,
int? overdueCount,
}) {
final List<Widget> badges = [];
final Widget? outstanding = buildOutstandingBadge(context, outstandingCount);
if (outstanding != null) {
badges.add(outstanding);
}
final Widget? overdue = buildOverdueBadge(context, overdueCount);
if (overdue != null) {
if (badges.isNotEmpty) {
badges.add(const SizedBox(width: 6));
}
badges.add(overdue);
}
if (badges.isEmpty) {
return null;
}
return Row(mainAxisSize: MainAxisSize.min, children: badges);
}
class InvenTreeHomePage extends StatefulWidget { class InvenTreeHomePage extends StatefulWidget {
const InvenTreeHomePage({Key? key}) : super(key: key); const InvenTreeHomePage({Key? key}) : super(key: key);
@@ -54,6 +142,8 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
// Reload the widget // Reload the widget
}); });
} }
_loadOrderCounts();
}); });
} }
@@ -71,6 +161,15 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
// Selected user profile // Selected user profile
UserProfile? _profile; UserProfile? _profile;
// Order counts (null = not loaded / not visible)
int? _buildOverdueCount;
int? _buildOutstandingCount;
int? _poOverdueCount;
int? _poOutstandingCount;
int? _soOverdueCount;
int? _soOutstandingCount;
int? _shipmentsPendingCount;
void _showParts(BuildContext context) { void _showParts(BuildContext context) {
if (!InvenTreeAPI().checkConnection()) return; if (!InvenTreeAPI().checkConnection()) return;
@@ -233,6 +332,107 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
as bool; as bool;
setState(() {}); setState(() {});
_loadOrderCounts();
}
/*
* Load the "overdue" and "outstanding" counts for build / purchase / sales orders,
* to be displayed as badges against the relevant home screen tiles.
* Only requests counts for tiles which are actually visible to the user.
*
* Each count is a separate cheap query (count() uses limit=1 server-side),
* rather than fetching the full outstanding order list and counting locally -
* that would require fetching *all* outstanding orders to be accurate.
*/
Future<void> _loadOrderCounts() async {
if (!InvenTreeAPI().isConnected()) {
return;
}
final bool loadBuild =
homeShowBuild && InvenTreeAPI().checkRole("build", "view");
final bool loadPo = homeShowPo && InvenTreePurchaseOrder().canView;
final bool loadSo = homeShowSo && InvenTreeSalesOrder().canView;
final bool loadShipments =
homeShowShipments && InvenTreeSalesOrderShipment().canView;
int? buildOverdue;
int? buildOutstanding;
int? poOverdue;
int? poOutstanding;
int? soOverdue;
int? soOutstanding;
int? shipmentsPending;
final List<Future<void>> requests = [];
if (loadBuild) {
requests.add(
InvenTreeBuildOrder().count(filters: {"overdue": "true"}).then((c) {
buildOverdue = c;
}),
);
requests.add(
InvenTreeBuildOrder().count(filters: {"outstanding": "true"}).then((c) {
buildOutstanding = c;
}),
);
}
if (loadPo) {
requests.add(
InvenTreePurchaseOrder().count(filters: {"overdue": "true"}).then((c) {
poOverdue = c;
}),
);
requests.add(
InvenTreePurchaseOrder().count(filters: {"outstanding": "true"}).then((
c,
) {
poOutstanding = c;
}),
);
}
if (loadSo) {
requests.add(
InvenTreeSalesOrder().count(filters: {"overdue": "true"}).then((c) {
soOverdue = c;
}),
);
requests.add(
InvenTreeSalesOrder().count(filters: {"outstanding": "true"}).then((c) {
soOutstanding = c;
}),
);
}
if (loadShipments) {
requests.add(
InvenTreeSalesOrderShipment()
.count(filters: {"order_outstanding": "true", "shipped": "false"})
.then((c) {
shipmentsPending = c;
}),
);
}
await Future.wait(requests);
if (!mounted) {
return;
}
setState(() {
_buildOverdueCount = buildOverdue;
_buildOutstandingCount = buildOutstanding;
_poOverdueCount = poOverdue;
_poOutstandingCount = poOutstanding;
_soOverdueCount = soOverdue;
_soOutstandingCount = soOutstanding;
_shipmentsPendingCount = shipmentsPending;
});
} }
Future<void> _loadProfile() async { Future<void> _loadProfile() async {
@@ -277,7 +477,7 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
child: ListTile( child: ListTile(
leading: Icon( leading: Icon(
icon, icon,
color: connected && allowed ? COLOR_ACTION : Colors.grey, color: connected && allowed ? COLOR_ACTION : COLOR_GRAY_LIGHT,
), ),
title: Text(label), title: Text(label),
trailing: trailing, trailing: trailing,
@@ -362,6 +562,11 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
}, },
role: "build", role: "build",
permission: "view", permission: "view",
trailing: buildOrderBadges(
context,
outstandingCount: _buildOutstandingCount,
overdueCount: _buildOverdueCount,
),
), ),
); );
} }
@@ -376,6 +581,11 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
callback: () { callback: () {
_showPurchaseOrders(context); _showPurchaseOrders(context);
}, },
trailing: buildOrderBadges(
context,
outstandingCount: _poOutstandingCount,
overdueCount: _poOverdueCount,
),
), ),
); );
} }
@@ -389,6 +599,11 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
callback: () { callback: () {
_showSalesOrders(context); _showSalesOrders(context);
}, },
trailing: buildOrderBadges(
context,
outstandingCount: _soOutstandingCount,
overdueCount: _soOverdueCount,
),
), ),
); );
} }
@@ -402,6 +617,7 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
callback: () { callback: () {
_showPendingShipments(context); _showPendingShipments(context);
}, },
trailing: buildOutstandingBadge(context, _shipmentsPendingCount),
), ),
); );
} }
@@ -420,22 +636,6 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
); );
} }
// TODO: Add these tiles back in once the features are fleshed out
/*
// Manufacturers
if (homeShowManufacturers) {
tiles.add(_listTile(
context,
L10().manufacturers,
TablerIcons.building_factory_2,
callback: () {
_showManufacturers(context);
}
));
}
*/
// Customers // Customers
if (homeShowCustomers) { if (homeShowCustomers) {
tiles.add( tiles.add(
@@ -473,7 +673,11 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
} else if (connecting) { } else if (connecting) {
title = L10().serverConnecting; title = L10().serverConnecting;
subtitle = serverAddress; subtitle = serverAddress;
leading = Spinner(icon: TablerIcons.loader_2, color: COLOR_PROGRESS); leading = SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(color: COLOR_PROGRESS, strokeWidth: 2),
);
} }
return Center( return Center(
@@ -523,12 +727,21 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
children: getListTiles(context), children: getListTiles(context),
childAspectRatio: aspect, childAspectRatio: aspect,
primary: false, primary: false,
// Ensure the grid is always draggable, even if the tiles don't fill the viewport,
// so that "pull down to refresh" works regardless of screen size / tile count
physics: const AlwaysScrollableScrollPhysics(),
crossAxisSpacing: padding, crossAxisSpacing: padding,
mainAxisSpacing: padding, mainAxisSpacing: padding,
padding: EdgeInsets.all(padding), padding: EdgeInsets.all(padding),
); );
} }
// Refresh handler for "pull down to refresh" on the home screen
Future<void> _onRefresh() async {
await _loadProfile();
await _loadOrderCounts();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var connected = InvenTreeAPI().isConnected(); var connected = InvenTreeAPI().isConnected();
@@ -545,38 +758,58 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
Text(L10().appTitle), Text(L10().appTitle),
], ],
), ),
backgroundColor: COLOR_APP_BAR,
actions: [ actions: [
IconButton( InkWell(
icon: Stack( onTap: _selectProfile,
children: [ child: Padding(
Icon(TablerIcons.server), padding: const EdgeInsets.symmetric(horizontal: 12),
Positioned( child: Row(
right: 0, mainAxisSize: MainAxisSize.min,
bottom: 0, children: [
child: Container( if (connected &&
width: 10, (InvenTreeAPI().profile?.name ?? "").isNotEmpty)
height: 10, ConstrainedBox(
decoration: BoxDecoration( constraints: const BoxConstraints(maxWidth: 120),
color: connected child: Padding(
? COLOR_SUCCESS padding: const EdgeInsets.only(right: 6),
: (connecting ? COLOR_PROGRESS : COLOR_DANGER), child: Text(
shape: BoxShape.circle, InvenTreeAPI().profile!.name,
border: Border.all( overflow: TextOverflow.ellipsis,
color: Theme.of(context).scaffoldBackgroundColor, maxLines: 1,
width: 1.5, ),
), ),
), ),
Stack(
children: [
Icon(TablerIcons.server),
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: connected
? COLOR_SUCCESS
: (connecting ? COLOR_PROGRESS : COLOR_DANGER),
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).scaffoldBackgroundColor,
width: 1.5,
),
),
),
),
],
), ),
), ],
], ),
), ),
onPressed: _selectProfile,
), ),
], ],
), ),
drawer: InvenTreeDrawer(context), drawer: InvenTreeDrawer(context),
body: getBody(context), body: RefreshIndicator(onRefresh: _onRefresh, child: getBody(context)),
bottomNavigationBar: InvenTreeAPI().isConnected() bottomNavigationBar: InvenTreeAPI().isConnected()
? buildBottomAppBar(context, homeKey) ? buildBottomAppBar(context, homeKey)
: null, : null,
+2 -1
View File
@@ -2,6 +2,7 @@ import "package:flutter/material.dart";
import "package:flutter_speed_dial/flutter_speed_dial.dart"; import "package:flutter_speed_dial/flutter_speed_dial.dart";
import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/l10.dart"; import "package:inventree/l10.dart";
import "package:inventree/inventree/model.dart"; import "package:inventree/inventree/model.dart";
import "package:inventree/inventree/purchase_order.dart"; import "package:inventree/inventree/purchase_order.dart";
@@ -53,7 +54,7 @@ class _PurchaseOrderExtraLineListWidgetState
if (widget.order.canEdit) { if (widget.order.canEdit) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.circle_plus, color: Colors.green), child: Icon(TablerIcons.circle_plus, color: COLOR_SUCCESS),
label: L10().lineItemAdd, label: L10().lineItemAdd,
onTap: () { onTap: () {
_addLineItem(context); _addLineItem(context);
+1 -1
View File
@@ -67,7 +67,7 @@ class _POLineDetailWidgetState extends RefreshableState<POLineDetailWidget> {
if (!widget.item.isComplete) { if (!widget.item.isComplete) {
buttons.add( buttons.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.transition_right, color: Colors.blue), child: Icon(TablerIcons.transition_right, color: COLOR_ACTION),
label: L10().receiveItem, label: L10().receiveItem,
onTap: () async { onTap: () async {
receiveLineItem(context); receiveLineItem(context);
+7 -7
View File
@@ -95,7 +95,7 @@ class _PurchaseOrderDetailState
if (showCameraShortcut && widget.order.canEdit) { if (showCameraShortcut && widget.order.canEdit) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.camera, color: Colors.blue), child: Icon(TablerIcons.camera, color: COLOR_ACTION),
label: L10().takePicture, label: L10().takePicture,
onTap: () async { onTap: () async {
_uploadImage(context); _uploadImage(context);
@@ -108,7 +108,7 @@ class _PurchaseOrderDetailState
if (widget.order.isPending) { if (widget.order.isPending) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.circle_plus, color: Colors.green), child: Icon(TablerIcons.circle_plus, color: COLOR_SUCCESS),
label: L10().lineItemAdd, label: L10().lineItemAdd,
onTap: () async { onTap: () async {
_addLineItem(context); _addLineItem(context);
@@ -118,7 +118,7 @@ class _PurchaseOrderDetailState
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.send, color: Colors.blue), child: Icon(TablerIcons.send, color: COLOR_ACTION),
label: L10().issueOrder, label: L10().issueOrder,
onTap: () async { onTap: () async {
_issueOrder(context); _issueOrder(context);
@@ -130,7 +130,7 @@ class _PurchaseOrderDetailState
if (widget.order.isOpen && !widget.order.isPending) { if (widget.order.isOpen && !widget.order.isPending) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.circle_check, color: Colors.green), child: Icon(TablerIcons.circle_check, color: COLOR_SUCCESS),
label: L10().completeOrder, label: L10().completeOrder,
onTap: () async { onTap: () async {
_completeOrder(context); _completeOrder(context);
@@ -142,7 +142,7 @@ class _PurchaseOrderDetailState
if (widget.order.isOpen) { if (widget.order.isOpen) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.circle_x, color: Colors.red), child: Icon(TablerIcons.circle_x, color: COLOR_DANGER),
label: L10().cancelOrder, label: L10().cancelOrder,
onTap: () async { onTap: () async {
_cancelOrder(context); _cancelOrder(context);
@@ -193,7 +193,7 @@ class _PurchaseOrderDetailState
L10().issueOrder, L10().issueOrder,
L10().issueOrderConfirm, L10().issueOrderConfirm,
icon: TablerIcons.send, icon: TablerIcons.send,
color: Colors.blue, color: COLOR_ACTION,
acceptText: L10().issue, acceptText: L10().issue,
onAccept: () async { onAccept: () async {
widget.order.issueOrder().then((dynamic) { widget.order.issueOrder().then((dynamic) {
@@ -227,7 +227,7 @@ class _PurchaseOrderDetailState
L10().cancelOrder, L10().cancelOrder,
L10().cancelOrderConfirm, L10().cancelOrderConfirm,
icon: TablerIcons.circle_x, icon: TablerIcons.circle_x,
color: Colors.red, color: COLOR_DANGER,
acceptText: L10().cancel, acceptText: L10().cancel,
onAccept: () async { onAccept: () async {
widget.order.cancelOrder().then((dynamic) { widget.order.cancelOrder().then((dynamic) {
+7 -7
View File
@@ -127,7 +127,7 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> {
L10().issueOrder, L10().issueOrder,
L10().issueOrderConfirm, L10().issueOrderConfirm,
icon: TablerIcons.send, icon: TablerIcons.send,
color: Colors.blue, color: COLOR_ACTION,
acceptText: L10().issue, acceptText: L10().issue,
onAccept: () async { onAccept: () async {
widget.order.issueOrder().then((dynamic) { widget.order.issueOrder().then((dynamic) {
@@ -143,7 +143,7 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> {
L10().cancelOrder, L10().cancelOrder,
L10().cancelOrderConfirm, L10().cancelOrderConfirm,
icon: TablerIcons.circle_x, icon: TablerIcons.circle_x,
color: Colors.red, color: COLOR_DANGER,
acceptText: L10().cancel, acceptText: L10().cancel,
onAccept: () async { onAccept: () async {
await widget.order.cancelOrder().then((dynamic) { await widget.order.cancelOrder().then((dynamic) {
@@ -160,7 +160,7 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> {
if (showCameraShortcut && widget.order.canEdit) { if (showCameraShortcut && widget.order.canEdit) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.camera, color: Colors.blue), child: Icon(TablerIcons.camera, color: COLOR_ACTION),
label: L10().takePicture, label: L10().takePicture,
onTap: () async { onTap: () async {
_uploadImage(context); _uploadImage(context);
@@ -172,7 +172,7 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> {
if (widget.order.isPending) { if (widget.order.isPending) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.send, color: Colors.blue), child: Icon(TablerIcons.send, color: COLOR_ACTION),
label: L10().issueOrder, label: L10().issueOrder,
onTap: () async { onTap: () async {
_issueOrder(context); _issueOrder(context);
@@ -184,7 +184,7 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> {
if (widget.order.isOpen) { if (widget.order.isOpen) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.circle_x, color: Colors.red), child: Icon(TablerIcons.circle_x, color: COLOR_DANGER),
label: L10().cancelOrder, label: L10().cancelOrder,
onTap: () async { onTap: () async {
_cancelOrder(context); _cancelOrder(context);
@@ -198,7 +198,7 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> {
InvenTreeSOLineItem().canCreate) { InvenTreeSOLineItem().canCreate) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.circle_plus, color: Colors.green), child: Icon(TablerIcons.circle_plus, color: COLOR_SUCCESS),
label: L10().lineItemAdd, label: L10().lineItemAdd,
onTap: () async { onTap: () async {
_addLineItem(context); _addLineItem(context);
@@ -208,7 +208,7 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.circle_plus, color: Colors.green), child: Icon(TablerIcons.circle_plus, color: COLOR_SUCCESS),
label: L10().shipmentAdd, label: L10().shipmentAdd,
onTap: () async { onTap: () async {
_addShipment(context); _addShipment(context);
+2 -1
View File
@@ -2,6 +2,7 @@ import "package:flutter/material.dart";
import "package:flutter_speed_dial/flutter_speed_dial.dart"; import "package:flutter_speed_dial/flutter_speed_dial.dart";
import "package:flutter_tabler_icons/flutter_tabler_icons.dart"; import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/l10.dart"; import "package:inventree/l10.dart";
import "package:inventree/inventree/model.dart"; import "package:inventree/inventree/model.dart";
@@ -55,7 +56,7 @@ class _SalesOrderExtraLineListWidgetState
if (widget.order.canEdit) { if (widget.order.canEdit) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.circle_plus, color: Colors.green), child: Icon(TablerIcons.circle_plus, color: COLOR_SUCCESS),
label: L10().lineItemAdd, label: L10().lineItemAdd,
onTap: () { onTap: () {
_addLineItem(context); _addLineItem(context);
+1 -1
View File
@@ -111,7 +111,7 @@ class _SOLineDetailWidgetState extends RefreshableState<SoLineDetailWidget> {
if (order != null && order!.isOpen) { if (order != null && order!.isOpen) {
buttons.add( buttons.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.transition_right, color: Colors.blue), child: Icon(TablerIcons.transition_right, color: COLOR_ACTION),
label: L10().allocateStock, label: L10().allocateStock,
onTap: () async { onTap: () async {
_allocateStock(context); _allocateStock(context);
+4 -4
View File
@@ -154,7 +154,7 @@ class _SOShipmentDetailWidgetState
if (showCameraShortcut) { if (showCameraShortcut) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.camera, color: Colors.blue), child: Icon(TablerIcons.camera, color: COLOR_ACTION),
label: L10().takePicture, label: L10().takePicture,
onTap: () async { onTap: () async {
_uploadImage(context); _uploadImage(context);
@@ -167,7 +167,7 @@ class _SOShipmentDetailWidgetState
if (!widget.shipment.isChecked && !widget.shipment.isShipped) { if (!widget.shipment.isChecked && !widget.shipment.isShipped) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.check, color: Colors.green), child: Icon(TablerIcons.check, color: COLOR_SUCCESS),
label: L10().shipmentCheck, label: L10().shipmentCheck,
onTap: () async { onTap: () async {
widget.shipment widget.shipment
@@ -185,7 +185,7 @@ class _SOShipmentDetailWidgetState
if (widget.shipment.isChecked && !widget.shipment.isShipped) { if (widget.shipment.isChecked && !widget.shipment.isShipped) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.x, color: Colors.red), child: Icon(TablerIcons.x, color: COLOR_DANGER),
label: L10().shipmentUncheck, label: L10().shipmentUncheck,
onTap: () async { onTap: () async {
widget.shipment.update(values: {"checked_by": null}).then((_) { widget.shipment.update(values: {"checked_by": null}).then((_) {
@@ -201,7 +201,7 @@ class _SOShipmentDetailWidgetState
if (!widget.shipment.isShipped) { if (!widget.shipment.isShipped) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.truck_delivery, color: Colors.green), child: Icon(TablerIcons.truck_delivery, color: COLOR_SUCCESS),
label: L10().shipmentSend, label: L10().shipmentSend,
onTap: () async { onTap: () async {
_sendShipment(context); _sendShipment(context);
+1 -1
View File
@@ -22,7 +22,7 @@ Widget ProgressBar(double value, {double maximum = 1.0}) {
return LinearProgressIndicator( return LinearProgressIndicator(
value: v, value: v,
backgroundColor: Colors.grey, backgroundColor: COLOR_GRAY_LIGHT,
color: v >= 1 ? COLOR_SUCCESS : COLOR_WARNING, color: v >= 1 ? COLOR_SUCCESS : COLOR_WARNING,
); );
} }
-1
View File
@@ -56,7 +56,6 @@ mixin BaseWidgetProperties {
centerTitle: false, centerTitle: false,
bottom: tabs.isEmpty ? null : TabBar(tabs: tabs), bottom: tabs.isEmpty ? null : TabBar(tabs: tabs),
title: Text(getAppBarTitle()), title: Text(getAppBarTitle()),
backgroundColor: COLOR_APP_BAR,
actions: appBarActions(context), actions: appBarActions(context),
leading: backButton(context, key), leading: backButton(context, key),
); );
+4 -3
View File
@@ -4,6 +4,7 @@ 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:one_context/one_context.dart"; import "package:one_context/one_context.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/helpers.dart"; import "package:inventree/helpers.dart";
import "package:inventree/l10.dart"; import "package:inventree/l10.dart";
@@ -33,16 +34,16 @@ void showSnackIcon(
_currentSnackOverlay?.remove(); _currentSnackOverlay?.remove();
_currentSnackOverlay = null; _currentSnackOverlay = null;
Color backgroundColor = Colors.deepOrange; Color backgroundColor = COLOR_DANGER;
if (success != null && success == true) { if (success != null && success == true) {
backgroundColor = Colors.lightGreen; backgroundColor = COLOR_SUCCESS;
if (icon == null && onAction == null) { if (icon == null && onAction == null) {
icon = TablerIcons.circle_check; icon = TablerIcons.circle_check;
} }
} else if (success != null && success == false) { } else if (success != null && success == false) {
backgroundColor = Colors.deepOrange; backgroundColor = COLOR_DANGER;
if (icon == null && onAction == null) { if (icon == null && onAction == null) {
icon = TablerIcons.exclamation_circle; icon = TablerIcons.exclamation_circle;
-45
View File
@@ -1,45 +0,0 @@
import "package:flutter/material.dart";
import "package:inventree/app_colors.dart";
class Spinner extends StatefulWidget {
const Spinner({
this.color = COLOR_GRAY_LIGHT,
Key? key,
required this.icon,
this.duration = const Duration(milliseconds: 1800),
}) : super(key: key);
final IconData? icon;
final Duration duration;
final Color color;
@override
_SpinnerState createState() => _SpinnerState();
}
class _SpinnerState extends State<Spinner> with SingleTickerProviderStateMixin {
late AnimationController? _controller;
Widget? _child;
@override
void initState() {
_controller = AnimationController(
vsync: this,
duration: Duration(milliseconds: 2000),
)..repeat();
_child = Icon(widget.icon, color: widget.color);
super.initState();
}
@override
void dispose() {
_controller!.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RotationTransition(turns: _controller!, child: _child);
}
}
+5 -5
View File
@@ -94,7 +94,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
if (!widget.item.isSerialized()) { if (!widget.item.isSerialized()) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.circle_check, color: Colors.blue), child: Icon(TablerIcons.circle_check, color: COLOR_ACTION),
label: L10().countStock, label: L10().countStock,
onTap: _countStockDialog, onTap: _countStockDialog,
), ),
@@ -102,7 +102,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.circle_minus, color: Colors.red), child: Icon(TablerIcons.circle_minus, color: COLOR_DANGER),
label: L10().removeStock, label: L10().removeStock,
onTap: _removeStockDialog, onTap: _removeStockDialog,
), ),
@@ -110,7 +110,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.circle_plus, color: Colors.green), child: Icon(TablerIcons.circle_plus, color: COLOR_SUCCESS),
label: L10().addStock, label: L10().addStock,
onTap: _addStockDialog, onTap: _addStockDialog,
), ),
@@ -144,7 +144,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
if (widget.item.canDelete) { if (widget.item.canDelete) {
actions.add( actions.add(
SpeedDialChild( SpeedDialChild(
child: Icon(TablerIcons.trash, color: Colors.red), child: Icon(TablerIcons.trash, color: COLOR_DANGER),
label: L10().stockItemDelete, label: L10().stockItemDelete,
onTap: () { onTap: () {
_deleteItem(context); _deleteItem(context);
@@ -322,7 +322,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
L10().stockItemDelete, L10().stockItemDelete,
L10().stockItemDeleteConfirm, L10().stockItemDeleteConfirm,
icon: TablerIcons.trash, icon: TablerIcons.trash,
color: Colors.red, color: COLOR_DANGER,
acceptText: L10().delete, acceptText: L10().delete,
onAccept: () async { onAccept: () async {
final bool result = await widget.item.delete(); final bool result = await widget.item.delete();
@@ -189,7 +189,7 @@ class _StockItemTestResultDisplayState
String _value = ""; String _value = "";
String _date = ""; String _date = "";
Widget _icon = Icon(TablerIcons.help_circle, color: Colors.lightBlue); Widget _icon = Icon(TablerIcons.help_circle, color: COLOR_GRAY_LIGHT);
bool _valueRequired = false; bool _valueRequired = false;
bool _attachmentRequired = false; bool _attachmentRequired = false;
@@ -214,7 +214,7 @@ class _StockItemTestResultDisplayState
} }
if (!_hasResult) { if (!_hasResult) {
_icon = Icon(TablerIcons.help_circle, color: Colors.blue); _icon = Icon(TablerIcons.help_circle, color: COLOR_GRAY_LIGHT);
} else if (_result == true) { } else if (_result == true) {
_icon = Icon(TablerIcons.circle_check, color: COLOR_SUCCESS); _icon = Icon(TablerIcons.circle_check, color: COLOR_SUCCESS);
} else if (_result == false) { } else if (_result == false) {
+52 -4
View File
@@ -5,10 +5,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: _fe_analyzer_shared name: _fe_analyzer_shared
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "93.0.0" version: "91.0.0"
adaptive_theme: adaptive_theme:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -21,10 +21,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: analyzer name: analyzer
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.0.1" version: "8.4.1"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@@ -459,6 +459,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.28" version: "2.0.28"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e"
url: "https://pub.dev"
source: hosted
version: "10.3.1"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5
url: "https://pub.dev"
source: hosted
version: "3.0.1"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
flutter_speed_dial: flutter_speed_dial:
dependency: "direct main" dependency: "direct main"
description: description:
+1
View File
@@ -27,6 +27,7 @@ dependencies:
flutter_localized_locales: ^2.0.5 flutter_localized_locales: ^2.0.5
flutter_markdown: ^0.6.19 # Rendering markdown flutter_markdown: ^0.6.19 # Rendering markdown
flutter_overlay_loader: ^2.0.0 # Overlay screen support flutter_overlay_loader: ^2.0.0 # Overlay screen support
flutter_secure_storage: ^10.3.1
flutter_speed_dial: ^6.2.0 # Speed dial / FAB implementation flutter_speed_dial: ^6.2.0 # Speed dial / FAB implementation
flutter_tabler_icons: ^1.43.0 # Tabler icons flutter_tabler_icons: ^1.43.0 # Tabler icons
http: ^1.4.0 http: ^1.4.0
+36
View File
@@ -22,6 +22,42 @@ void setupTestEnv() {
.setMockMethodCallHandler(channel, (MethodCall methodCall) async { .setMockMethodCallHandler(channel, (MethodCall methodCall) async {
return "."; return ".";
}); });
// Mock secure storage (used for storing profile API tokens) with a
// simple in-memory map, since there's no real platform keychain available
// in the test harness
final Map<String, String> secureStorageMock = {};
const MethodChannel secureStorageChannel = MethodChannel(
"plugins.it_nomads.com/flutter_secure_storage",
);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(secureStorageChannel, (
MethodCall methodCall,
) async {
final Map<dynamic, dynamic> args =
(methodCall.arguments as Map<dynamic, dynamic>?) ?? {};
final String? key = args["key"] as String?;
switch (methodCall.method) {
case "write":
secureStorageMock[key!] = args["value"] as String;
return null;
case "read":
return secureStorageMock[key];
case "containsKey":
return secureStorageMock.containsKey(key);
case "delete":
secureStorageMock.remove(key);
return null;
case "deleteAll":
secureStorageMock.clear();
return null;
case "readAll":
return secureStorageMock;
default:
return null;
}
});
} }
// Accessors for default testing values // Accessors for default testing values
+145
View File
@@ -0,0 +1,145 @@
import "package:flutter/material.dart";
import "package:flutter_test/flutter_test.dart";
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/widget/home.dart";
void main() {
Future<void> pumpBadge(
WidgetTester tester,
Widget? Function(BuildContext context) buildBadge,
) async {
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
return Scaffold(body: buildBadge(context) ?? const SizedBox());
},
),
),
);
}
group("buildOverdueBadge", () {
testWidgets("renders a badge when there is an overdue count", (
WidgetTester tester,
) async {
await pumpBadge(tester, (context) => buildOverdueBadge(context, 5));
expect(find.text("5"), findsOneWidget);
expect(find.byIcon(TablerIcons.calendar_exclamation), findsOneWidget);
});
testWidgets("renders nothing when the overdue count is zero", (
WidgetTester tester,
) async {
await pumpBadge(tester, (context) => buildOverdueBadge(context, 0));
expect(find.text("0"), findsNothing);
});
testWidgets("renders nothing when the overdue count is not loaded", (
WidgetTester tester,
) async {
await pumpBadge(tester, (context) => buildOverdueBadge(context, null));
expect(find.byType(Container), findsNothing);
});
});
group("buildOutstandingBadge", () {
testWidgets("renders a badge when there is an outstanding count", (
WidgetTester tester,
) async {
await pumpBadge(tester, (context) => buildOutstandingBadge(context, 3));
expect(find.text("3"), findsOneWidget);
expect(find.byIcon(TablerIcons.progress), findsOneWidget);
});
testWidgets("uses a less prominent color than the overdue badge", (
WidgetTester tester,
) async {
late Color outstandingColor;
late Color overdueColor;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
final ColorScheme colors = Theme.of(context).colorScheme;
outstandingColor = colors.secondaryContainer;
overdueColor = colors.errorContainer;
return Scaffold(
body: Column(
children: [
buildOutstandingBadge(context, 3)!,
buildOverdueBadge(context, 3)!,
],
),
);
},
),
),
);
final containers = tester
.widgetList<Container>(find.byType(Container))
.toList();
final Color outstandingBadgeColor =
(containers[0].decoration! as BoxDecoration).color!;
final Color overdueBadgeColor =
(containers[1].decoration! as BoxDecoration).color!;
expect(outstandingBadgeColor, outstandingColor);
expect(overdueBadgeColor, overdueColor);
expect(outstandingBadgeColor, isNot(equals(overdueBadgeColor)));
});
});
group("buildOrderBadges", () {
testWidgets("renders nothing when both counts are empty", (
WidgetTester tester,
) async {
await pumpBadge(
tester,
(context) =>
buildOrderBadges(context, outstandingCount: null, overdueCount: 0),
);
expect(find.byType(Container), findsNothing);
});
testWidgets("renders both badges side by side when both counts are set", (
WidgetTester tester,
) async {
await pumpBadge(
tester,
(context) =>
buildOrderBadges(context, outstandingCount: 7, overdueCount: 2),
);
expect(find.text("7"), findsOneWidget);
expect(find.text("2"), findsOneWidget);
expect(find.byIcon(TablerIcons.progress), findsOneWidget);
expect(find.byIcon(TablerIcons.calendar_exclamation), findsOneWidget);
// One outer Row combining the badges, plus one inner Row per badge
expect(find.byType(Row), findsNWidgets(3));
});
testWidgets("renders only the overdue badge when outstanding is zero", (
WidgetTester tester,
) async {
await pumpBadge(
tester,
(context) =>
buildOrderBadges(context, outstandingCount: 0, overdueCount: 4),
);
expect(find.text("4"), findsOneWidget);
expect(find.byType(Container), findsOneWidget);
});
});
}