mirror of
https://github.com/inventree/inventree-app.git
synced 2026-07-21 19:55:55 +00:00
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:
+201
-29
@@ -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()) {
|
||||||
|
final UserProfile? expiredProfile = profile;
|
||||||
|
|
||||||
|
// Invalidate the token
|
||||||
|
if (expiredProfile != null) {
|
||||||
|
expiredProfile.token = "";
|
||||||
|
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(
|
showServerError(
|
||||||
_URL_ME,
|
_URL_ME,
|
||||||
L10().serverNotConnected,
|
L10().serverNotConnected,
|
||||||
L10().serverAuthenticationError,
|
L10().serverAuthenticationError,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Invalidate the token
|
|
||||||
if (profile != null) {
|
|
||||||
profile!.token = "";
|
|
||||||
await UserProfileDBManager().updateProfile(profile!);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,8 +632,11 @@ class InvenTreeAPI {
|
|||||||
headers: {HttpHeaders.authorizationHeader: authHeader},
|
headers: {HttpHeaders.authorizationHeader: authHeader},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final data = response.asMap();
|
||||||
|
|
||||||
// Invalid response
|
// Invalid response
|
||||||
if (!response.successful()) {
|
if (!response.successful()) {
|
||||||
|
if (showDialog) {
|
||||||
switch (response.statusCode) {
|
switch (response.statusCode) {
|
||||||
case 401:
|
case 401:
|
||||||
case 403:
|
case 403:
|
||||||
@@ -622,23 +648,25 @@ class InvenTreeAPI {
|
|||||||
default:
|
default:
|
||||||
showStatusCodeError(apiUrl, response.statusCode);
|
showStatusCodeError(apiUrl, response.statusCode);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
debug("Token request failed: STATUS ${response.statusCode}");
|
debug("Token request failed: STATUS ${response.statusCode}");
|
||||||
|
|
||||||
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
|
||||||
userProfile.token = (data["token"] ?? "") as String;
|
userProfile.token = (data["token"] ?? "") as String;
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set to null to use the system default
|
return Theme.of(context).colorScheme;
|
||||||
Color? COLOR_APP_BAR;
|
}
|
||||||
|
|
||||||
const Color COLOR_WARNING = Color.fromRGBO(250, 150, 50, 1);
|
// Semantic colors, derived from the current theme's ColorScheme.
|
||||||
const Color COLOR_DANGER = Color.fromRGBO(200, 50, 75, 1);
|
// Material 3 has no dedicated "success"/"warning" roles, so those map onto
|
||||||
const Color COLOR_SUCCESS = Color.fromRGBO(100, 200, 75, 1);
|
// the nearest available accent (tertiary / secondary respectively).
|
||||||
const Color COLOR_PROGRESS = Color.fromRGBO(50, 100, 200, 1);
|
Color get COLOR_ACTION => Colors.blue;
|
||||||
const Color COLOR_GRAY_LIGHT = Color.fromRGBO(150, 150, 150, 1);
|
Color get COLOR_WARNING => Colors.orange;
|
||||||
|
Color get COLOR_DANGER => _colorScheme.error;
|
||||||
|
Color get COLOR_SUCCESS => Colors.lightGreen;
|
||||||
|
Color get COLOR_PROGRESS => Colors.lightBlue;
|
||||||
|
Color get COLOR_GRAY_LIGHT => _colorScheme.onSurfaceVariant;
|
||||||
|
Color get COLOR_TEXT => _colorScheme.onSurface;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {},
|
||||||
|
|
||||||
|
|||||||
@@ -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", "")
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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,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: [
|
||||||
|
|||||||
+13
-5
@@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (response.successful()) {
|
||||||
|
// A token was issued - immediately connect using it, then return
|
||||||
|
// 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();
|
hideLoadingOverlay();
|
||||||
|
|
||||||
if (response.successful()) {
|
if (context.mounted) {
|
||||||
// Return to the server selector screen
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
Navigator.of(context).pop();
|
}
|
||||||
} 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),
|
||||||
|
|||||||
@@ -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,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: [
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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,35 +164,22 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
/*
|
||||||
Widget build(BuildContext context) {
|
* Show the profile action menu (Connect / Edit / Logout / Delete).
|
||||||
List<Widget> children = [];
|
* Reachable via the always-visible trailing "more" button, or long-press
|
||||||
|
* as a bonus shortcut.
|
||||||
if (profiles.isNotEmpty) {
|
*/
|
||||||
for (int idx = 0; idx < profiles.length; idx++) {
|
void _showProfileActions(BuildContext context, UserProfile profile) {
|
||||||
UserProfile profile = profiles[idx];
|
|
||||||
|
|
||||||
children.add(
|
|
||||||
ListTile(
|
|
||||||
title: Text(profile.name),
|
|
||||||
tileColor: profile.selected
|
|
||||||
? Theme.of(context).secondaryHeaderColor
|
|
||||||
: null,
|
|
||||||
subtitle: Text("${profile.server}"),
|
|
||||||
leading: profile.hasToken
|
|
||||||
? Icon(TablerIcons.user_check, color: COLOR_SUCCESS)
|
|
||||||
: Icon(TablerIcons.user_cancel, color: COLOR_WARNING),
|
|
||||||
trailing: _getProfileIcon(profile),
|
|
||||||
onTap: () {
|
|
||||||
_selectProfile(context, profile);
|
|
||||||
},
|
|
||||||
onLongPress: () {
|
|
||||||
OneContext().showDialog(
|
OneContext().showDialog(
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return SimpleDialog(
|
return SimpleDialog(
|
||||||
@@ -220,11 +220,10 @@ class _InvenTreeSelectServerState extends State<InvenTreeSelectServerWidget> {
|
|||||||
SimpleDialogOption(
|
SimpleDialogOption(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
// Navigator.of(context, rootNavigator: true).pop();
|
|
||||||
confirmationDialog(
|
confirmationDialog(
|
||||||
L10().delete,
|
L10().delete,
|
||||||
L10().profileDelete + "?",
|
L10().profileDelete + "?",
|
||||||
color: Colors.red,
|
color: COLOR_DANGER,
|
||||||
icon: TablerIcons.trash,
|
icon: TablerIcons.trash,
|
||||||
onAccept: () {
|
onAccept: () {
|
||||||
_deleteProfile(profile);
|
_deleteProfile(profile);
|
||||||
@@ -234,15 +233,53 @@ class _InvenTreeSelectServerState extends State<InvenTreeSelectServerWidget> {
|
|||||||
child: ListTile(
|
child: ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
L10().profileDelete,
|
L10().profileDelete,
|
||||||
style: TextStyle(color: Colors.red),
|
style: TextStyle(color: COLOR_DANGER),
|
||||||
),
|
),
|
||||||
leading: Icon(TablerIcons.trash, color: Colors.red),
|
leading: Icon(TablerIcons.trash, color: COLOR_DANGER),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
List<Widget> children = [];
|
||||||
|
|
||||||
|
if (profiles.isNotEmpty) {
|
||||||
|
for (int idx = 0; idx < profiles.length; idx++) {
|
||||||
|
UserProfile profile = profiles[idx];
|
||||||
|
|
||||||
|
children.add(
|
||||||
|
ListTile(
|
||||||
|
title: Text(profile.name),
|
||||||
|
tileColor: profile.selected
|
||||||
|
? Theme.of(context).secondaryHeaderColor
|
||||||
|
: null,
|
||||||
|
subtitle: Text("${profile.server}"),
|
||||||
|
leading: profile.hasToken
|
||||||
|
? Icon(TablerIcons.user_check, color: COLOR_SUCCESS)
|
||||||
|
: Icon(TablerIcons.user_cancel, color: COLOR_WARNING),
|
||||||
|
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: () {
|
||||||
|
_selectProfile(context, profile);
|
||||||
|
},
|
||||||
|
onLongPress: () {
|
||||||
|
_showProfileActions(context, profile);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,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: [
|
||||||
|
|||||||
+92
-15
@@ -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,25 +244,41 @@ 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,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
profileList.add(await _loadToken(profile));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there are no available profiles, create a demo profile
|
return profileList;
|
||||||
if (profileList.isEmpty) {
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 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(
|
bool added = await InvenTreeSettingsManager().getBool(
|
||||||
"demo_profile_added",
|
"demo_profile_added",
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Don't add a new profile if we have added it previously
|
// Don't add a new profile if we have added it previously
|
||||||
if (!added) {
|
if (added) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await InvenTreeSettingsManager().setValue("demo_profile_added", true);
|
await InvenTreeSettingsManager().setValue("demo_profile_added", true);
|
||||||
|
|
||||||
UserProfile demoProfile = UserProfile(
|
UserProfile demoProfile = UserProfile(
|
||||||
@@ -204,12 +287,6 @@ class UserProfileDBManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await addProfile(demoProfile);
|
await addProfile(demoProfile);
|
||||||
|
|
||||||
profileList.add(demoProfile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return profileList;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
+257
-24
@@ -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,10 +758,28 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
|
|||||||
Text(L10().appTitle),
|
Text(L10().appTitle),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
backgroundColor: COLOR_APP_BAR,
|
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
InkWell(
|
||||||
icon: Stack(
|
onTap: _selectProfile,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (connected &&
|
||||||
|
(InvenTreeAPI().profile?.name ?? "").isNotEmpty)
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 120),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 6),
|
||||||
|
child: Text(
|
||||||
|
InvenTreeAPI().profile!.name,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Stack(
|
||||||
children: [
|
children: [
|
||||||
Icon(TablerIcons.server),
|
Icon(TablerIcons.server),
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -571,12 +802,14 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
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,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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user