UI updates (#849)

* Updated dashboard

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

* Remove dead code

* Refactor app colors

* Add "pending shipments" icon

* Remove custom spinner

* Refactor error dialog

* Updated redirect after login

* Refactor login/account pages

- Better UX and confirmation messages

* Refactor API error messages

* Improve token management

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

* Improved error messages
This commit is contained in:
Oliver
2026-07-06 21:10:10 +10:00
committed by GitHub
parent f737172180
commit 349a8e0ef5
45 changed files with 1240 additions and 477 deletions
+201 -29
View File
@@ -25,6 +25,7 @@ import "package:inventree/inventree/model.dart";
import "package:inventree/inventree/notification.dart";
import "package:inventree/inventree/status_codes.dart";
import "package:inventree/inventree/sentry.dart";
import "package:inventree/settings/login.dart";
import "package:inventree/user_profile.dart";
import "package:inventree/widget/dialogs.dart";
import "package:inventree/widget/snacks.dart";
@@ -191,8 +192,11 @@ class InvenTreeAPI {
}
}
// Minimum required API version for server
// 2023-03-04
// Minimum required API version for server.
// 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;
bool _strictHttps = false;
@@ -430,16 +434,34 @@ class InvenTreeAPI {
}
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(
_URL_ME,
L10().serverNotConnected,
L10().serverAuthenticationError,
);
// Invalidate the token
if (profile != null) {
profile!.token = "";
await UserProfileDBManager().updateProfile(profile!);
}
return false;
@@ -557,8 +579,9 @@ class InvenTreeAPI {
Future<APIResponse> fetchToken(
UserProfile userProfile,
String username,
String password,
) async {
String password, {
bool showDialog = true,
}) async {
debug("Fetching user token from ${userProfile.server}");
profile = userProfile;
@@ -609,8 +632,11 @@ class InvenTreeAPI {
headers: {HttpHeaders.authorizationHeader: authHeader},
);
final data = response.asMap();
// Invalid response
if (!response.successful()) {
if (showDialog) {
switch (response.statusCode) {
case 401:
case 403:
@@ -622,23 +648,25 @@ class InvenTreeAPI {
default:
showStatusCodeError(apiUrl, response.statusCode);
}
}
debug("Token request failed: STATUS ${response.statusCode}");
if (response.data != null) {
debug("Response data: ${response.data.toString()}");
}
}
final data = response.asMap();
if (!data.containsKey("token")) {
} else if (!data.containsKey("token")) {
// The request was otherwise successful, but the response is missing
// the expected token field - a distinct (and much rarer) problem from
// an authentication failure, so only reachable when that did NOT occur
if (showDialog) {
showServerError(
apiUrl,
L10().tokenMissing,
L10().tokenMissingFromResponse,
);
}
}
// Save the token to the user profile
userProfile.token = (data["token"] ?? "") as String;
@@ -945,7 +973,11 @@ class InvenTreeAPI {
});
} on SocketException catch (error) {
debug("SocketException at ${url}: ${error.toString()}");
showServerError(url, L10().connectionRefused, error.toString());
showServerError(
url,
L10().connectionRefused,
L10().connectionRefusedDetail,
);
return;
} on TimeoutException {
debug("TimeoutException at ${url}");
@@ -980,8 +1012,12 @@ class InvenTreeAPI {
} else {
showStatusCodeError(url, response.statusCode);
}
} on SocketException catch (error) {
showServerError(url, L10().connectionRefused, error.toString());
} on SocketException {
showServerError(
url,
L10().connectionRefused,
L10().connectionRefusedDetail,
);
} on TimeoutException {
showTimeoutError(url);
} catch (error, stackTrace) {
@@ -1063,7 +1099,11 @@ class InvenTreeAPI {
);
}
} on SocketException catch (error) {
showServerError(url, L10().connectionRefused, error.toString());
showServerError(
url,
L10().connectionRefused,
L10().connectionRefusedDetail,
);
response.error = "SocketException";
response.errorDetail = error.toString();
} on FormatException {
@@ -1190,12 +1230,13 @@ class InvenTreeAPI {
client.badCertificateCallback =
(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) {
showServerError(
"${host}:${port}",
L10().serverCertificateError,
L10().serverCertificateInvalid,
);
return false;
}
@@ -1287,10 +1328,10 @@ class InvenTreeAPI {
HttpClientRequest? _request;
// 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 {
_request = await httpClient
.openUrl(method, _uri)
.timeout(Duration(seconds: 10));
_request = await _openUrlWithRetry(method, _uri);
// Default headers
defaultHeaders().forEach((key, value) {
@@ -1305,7 +1346,11 @@ class InvenTreeAPI {
return _request;
} on SocketException catch (error) {
debug("SocketException at ${url}: ${error.toString()}");
showServerError(url, L10().connectionRefused, error.toString());
showServerError(
url,
L10().connectionRefused,
L10().connectionRefusedDetail,
);
return null;
} on TimeoutException {
debug("TimeoutException at ${url}");
@@ -1313,14 +1358,36 @@ class InvenTreeAPI {
return null;
} on OSError catch (error) {
debug("OSError at ${url}: ${error.toString()}");
showServerError(url, L10().connectionRefused, error.toString());
showServerError(
url,
L10().connectionRefused,
L10().connectionRefusedDetail,
);
return null;
} on CertificateException catch (error) {
final HttpClientRequest? retried = await _retryAfterCertificateTrust(
url,
method,
_uri,
headers,
);
if (retried != null) {
return retried;
}
debug("CertificateException at ${url}:");
debug(error.toString());
showServerError(url, L10().serverCertificateError, error.toString());
return null;
} on HandshakeException catch (error) {
final HttpClientRequest? retried = await _retryAfterCertificateTrust(
url,
method,
_uri,
headers,
);
if (retried != null) {
return retried;
}
debug("HandshakeException at ${url}:");
debug(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
*/
@@ -1414,7 +1582,11 @@ class InvenTreeAPI {
response.error = "HTTPException";
response.errorDetail = error.toString();
} on SocketException catch (error) {
showServerError(url, L10().connectionRefused, error.toString());
showServerError(
url,
L10().connectionRefused,
L10().connectionRefusedDetail,
);
response.error = "SocketException";
response.errorDetail = error.toString();
} on CertificateException catch (error) {
-1
View File
@@ -1612,7 +1612,6 @@ class APIFormWidgetState extends State<APIFormWidget> {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
backgroundColor: COLOR_APP_BAR,
actions: [
IconButton(
icon: Icon(widget.icon),
+19 -14
View File
@@ -17,20 +17,25 @@ bool isDarkMode() {
return AdaptiveTheme.of(context).brightness == Brightness.dark;
}
// Return an "action" color based on the current theme
Color get COLOR_ACTION {
if (isDarkMode()) {
return Colors.lightBlueAccent;
} else {
return Colors.blue;
// Resolve the app's current ColorScheme, falling back to a sensible default
// if no BuildContext is available yet (e.g. very early app startup).
ColorScheme get _colorScheme {
final BuildContext? context = OneContext().context;
if (context == null) {
return const ColorScheme.light();
}
return Theme.of(context).colorScheme;
}
// Set to null to use the system default
Color? COLOR_APP_BAR;
const Color COLOR_WARNING = Color.fromRGBO(250, 150, 50, 1);
const Color COLOR_DANGER = Color.fromRGBO(200, 50, 75, 1);
const Color COLOR_SUCCESS = Color.fromRGBO(100, 200, 75, 1);
const Color COLOR_PROGRESS = Color.fromRGBO(50, 100, 200, 1);
const Color COLOR_GRAY_LIGHT = Color.fromRGBO(150, 150, 150, 1);
// Semantic colors, derived from the current theme's ColorScheme.
// Material 3 has no dedicated "success"/"warning" roles, so those map onto
// the nearest available accent (tertiary / secondary respectively).
Color get COLOR_ACTION => Colors.blue;
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;
+1 -4
View File
@@ -370,10 +370,7 @@ class _CameraBarcodeControllerState extends InvenTreeBarcodeControllerState {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: COLOR_APP_BAR,
title: Text(L10().scanBarcode),
),
appBar: AppBar(title: Text(L10().scanBarcode)),
floatingActionButton: buildActions(context),
body: GestureDetector(
onTap: () async {
+1 -4
View File
@@ -94,10 +94,7 @@ class _WedgeBarcodeControllerState extends InvenTreeBarcodeControllerState {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: COLOR_APP_BAR,
title: Text(L10().scanBarcode),
),
appBar: AppBar(title: Text(L10().scanBarcode)),
backgroundColor: Colors.black.withValues(alpha: 0.9),
body: Center(
child: Column(
+7 -6
View File
@@ -8,6 +8,7 @@ import "package:flutter/material.dart";
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/api.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/inventree/model.dart";
import "package:inventree/inventree/orders.dart";
import "package:inventree/inventree/part.dart";
@@ -380,17 +381,17 @@ class BuildOrderStatus {
static Color getStatusColor(int status) {
switch (status) {
case PENDING:
return Colors.blue;
return COLOR_GRAY_LIGHT;
case PRODUCTION:
return Colors.green;
return COLOR_PROGRESS;
case COMPLETE:
return Colors.purple;
return COLOR_SUCCESS;
case CANCELLED:
return Colors.red;
return COLOR_DANGER;
case ON_HOLD:
return Colors.orange;
return COLOR_WARNING;
default:
return Colors.grey;
return COLOR_GRAY_LIGHT;
}
}
+4 -4
View File
@@ -129,17 +129,17 @@ class InvenTreeStatusCode {
case "primary":
return COLOR_PROGRESS;
case "secondary":
return Colors.grey;
return COLOR_GRAY_LIGHT;
case "dark":
return Colors.black;
return COLOR_TEXT;
case "danger":
return COLOR_DANGER;
case "warning":
return COLOR_WARNING;
case "info":
return Colors.lightBlue;
return COLOR_PROGRESS;
default:
return Colors.black;
return COLOR_GRAY_LIGHT;
}
}
}
+18
View File
@@ -351,9 +351,15 @@
"connectionCheck": "Check Connection",
"@connectionCheck": {},
"connectionCheckDetail": "Checks that the server address is reachable. This does not verify your username or password.",
"@connectionCheckDetail": {},
"connectionRefused": "Connection Refused",
"@connectionRefused": {},
"connectionRefusedDetail": "Could not reach the server. Check that the address is correct and the server is running.",
"@connectionRefusedDetail": {},
"count": "Count",
"@count": {
"description": "Count"
@@ -1121,6 +1127,9 @@
"profileConnect": "Connect to Server",
"@profileConnect": {},
"profileSwitchConfirm": "This will disconnect your current session. Switch server profile?",
"@profileSwitchConfirm": {},
"profileEdit": "Edit Server Profile",
"@profileEdit": {},
@@ -1463,12 +1472,21 @@
"serverAuthenticationError": "Authentication Error",
"@serverAuthenticationError": {},
"sessionExpired": "Session Expired",
"@sessionExpired": {},
"sessionExpiredDetail": "Your session has expired. Please sign in again.",
"@sessionExpiredDetail": {},
"serverCertificateError": "Cerficate Error",
"@serverCertificateError": {},
"serverCertificateInvalid": "Server HTTPS certificate is invalid",
"@serverCertificateInvalid": {},
"serverCertificateTrust": "This server's certificate could not be verified. Trust it anyway?",
"@serverCertificateTrust": {},
"serverConnected": "Connected to Server",
"@serverConnected": {},
+4
View File
@@ -16,6 +16,7 @@ import "package:inventree/inventree/sentry.dart";
import "package:inventree/l10n/supported_locales.dart";
import "package:inventree/l10n/collected/app_localizations.dart";
import "package:inventree/settings/release.dart";
import "package:inventree/user_profile.dart";
import "package:inventree/widget/home.dart";
Future<void> main() async {
@@ -123,6 +124,9 @@ class InvenTreeAppState extends State<StatefulWidget> {
Locale? locale = await InvenTreeSettingsManager().getSelectedLocale();
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
final String version =
await InvenTreeSettingsManager().getValue("recentVersion", "")
+1 -4
View File
@@ -289,10 +289,7 @@ class InvenTreeAboutWidget extends StatelessWidget {
);
return Scaffold(
appBar: AppBar(
title: Text(L10().appAbout),
backgroundColor: COLOR_APP_BAR,
),
appBar: AppBar(title: Text(L10().appAbout)),
body: ListView(
children: ListTile.divideTiles(context: context, tiles: tiles).toList(),
),
+1 -4
View File
@@ -163,10 +163,7 @@ class _InvenTreeAppSettingsState extends State<InvenTreeAppSettingsWidget> {
return Scaffold(
key: _settingsKey,
appBar: AppBar(
title: Text(L10().appSettings),
backgroundColor: COLOR_APP_BAR,
),
appBar: AppBar(title: Text(L10().appSettings)),
body: Container(
child: ListView(
children: [
+4 -7
View File
@@ -1,9 +1,9 @@
import "package:flutter/material.dart";
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/l10.dart";
import "package:inventree/preferences.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/widget/dialogs.dart";
@@ -68,7 +68,7 @@ class _InvenTreeBarcodeSettingsState
),
actions: <Widget>[
MaterialButton(
color: Colors.red,
color: COLOR_DANGER,
textColor: Colors.white,
child: Text(L10().cancel),
onPressed: () {
@@ -78,7 +78,7 @@ class _InvenTreeBarcodeSettingsState
},
),
MaterialButton(
color: Colors.green,
color: COLOR_SUCCESS,
textColor: Colors.white,
child: Text(L10().ok),
onPressed: () async {
@@ -120,10 +120,7 @@ class _InvenTreeBarcodeSettingsState
}
return Scaffold(
appBar: AppBar(
title: Text(L10().barcodeSettings),
backgroundColor: COLOR_APP_BAR,
),
appBar: AppBar(title: Text(L10().barcodeSettings)),
body: Container(
child: ListView(
children: [
+1 -5
View File
@@ -1,6 +1,5 @@
import "package:flutter/material.dart";
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/l10.dart";
import "package:inventree/preferences.dart";
@@ -72,10 +71,7 @@ class _HomeScreenSettingsState extends State<HomeScreenSettingsWidget> {
Widget build(BuildContext context) {
return Scaffold(
key: _settingsKey,
appBar: AppBar(
title: Text(L10().homeScreen),
backgroundColor: COLOR_APP_BAR,
),
appBar: AppBar(title: Text(L10().homeScreen)),
body: Container(
child: ListView(
children: [
+13 -5
View File
@@ -44,19 +44,28 @@ class _InvenTreeLoginState extends State<InvenTreeLoginWidget> {
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(
widget.profile,
username,
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();
if (response.successful()) {
// Return to the server selector screen
Navigator.of(context).pop();
if (context.mounted) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
} else {
hideLoadingOverlay();
var data = response.asMap();
String err;
@@ -104,7 +113,6 @@ class _InvenTreeLoginState extends State<InvenTreeLoginWidget> {
return Scaffold(
appBar: AppBar(
title: Text(L10().login),
backgroundColor: COLOR_APP_BAR,
actions: [
IconButton(
icon: Icon(TablerIcons.transition_right, color: COLOR_SUCCESS),
+1 -5
View File
@@ -2,7 +2,6 @@ import "package:flutter/material.dart";
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/l10.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/preferences.dart";
class InvenTreePartSettingsWidget extends StatefulWidget {
@@ -46,10 +45,7 @@ class _InvenTreePartSettingsState extends State<InvenTreePartSettingsWidget> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(L10().partSettings),
backgroundColor: COLOR_APP_BAR,
),
appBar: AppBar(title: Text(L10().partSettings)),
body: Container(
child: ListView(
children: [
+1 -5
View File
@@ -1,6 +1,5 @@
import "package:flutter/material.dart";
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/l10.dart";
import "package:inventree/preferences.dart";
@@ -45,10 +44,7 @@ class _InvenTreePurchaseOrderSettingsState
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(L10().purchaseOrderSettings),
backgroundColor: COLOR_APP_BAR,
),
appBar: AppBar(title: Text(L10().purchaseOrderSettings)),
body: Container(
child: ListView(
children: [
+2 -9
View File
@@ -1,6 +1,5 @@
import "package:flutter/material.dart";
import "package:flutter_markdown/flutter_markdown.dart";
import "package:inventree/app_colors.dart";
import "package:url_launcher/url_launcher.dart";
import "package:inventree/l10.dart";
@@ -14,10 +13,7 @@ class ReleaseNotesWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(L10().releaseNotes),
backgroundColor: COLOR_APP_BAR,
),
appBar: AppBar(title: Text(L10().releaseNotes)),
body: Markdown(
selectable: false,
data: releaseNotes,
@@ -49,10 +45,7 @@ class CreditsWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(L10().credits),
backgroundColor: COLOR_APP_BAR,
),
appBar: AppBar(title: Text(L10().credits)),
body: Markdown(
selectable: false,
data: credits,
+1 -5
View File
@@ -2,7 +2,6 @@ import "package:flutter/material.dart";
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/l10.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/preferences.dart";
class InvenTreeSalesOrderSettingsWidget extends StatefulWidget {
@@ -40,10 +39,7 @@ class _InvenTreeSalesOrderSettingsState
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(L10().salesOrderSettings),
backgroundColor: COLOR_APP_BAR,
),
appBar: AppBar(title: Text(L10().salesOrderSettings)),
body: Container(
child: ListView(
children: [
+93 -50
View File
@@ -6,7 +6,6 @@ import "package:inventree/settings/login.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/widget/dialogs.dart";
import "package:inventree/widget/spinner.dart";
import "package:inventree/l10.dart";
import "package:inventree/api.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 {
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
InvenTreeAPI().disconnectFromServer();
@@ -90,22 +116,13 @@ class _InvenTreeSelectServerState extends State<InvenTreeSelectServerWidget> {
// First check if the profile has an associate token
if (!prf.hasToken) {
// Redirect user to login screen
Navigator.push(
// Redirect user to the login screen - it connects on success itself
await Navigator.push(
context,
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;
}
@@ -113,12 +130,8 @@ class _InvenTreeSelectServerState extends State<InvenTreeSelectServerWidget> {
return;
}
_reload();
// Attempt server login (this will load the newly selected profile
InvenTreeAPI().connectToServer(prf).then((result) {
_reload();
});
// Attempt server connection using the existing token
await InvenTreeAPI().connectToServer(prf);
_reload();
}
@@ -151,35 +164,22 @@ class _InvenTreeSelectServerState extends State<InvenTreeSelectServerWidget> {
if (InvenTreeAPI().isConnected()) {
return Icon(TablerIcons.circle_check, color: COLOR_SUCCESS);
} 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 {
return Icon(TablerIcons.circle_x, 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: _getProfileIcon(profile),
onTap: () {
_selectProfile(context, profile);
},
onLongPress: () {
/*
* Show the profile action menu (Connect / Edit / Logout / Delete).
* Reachable via the always-visible trailing "more" button, or long-press
* as a bonus shortcut.
*/
void _showProfileActions(BuildContext context, UserProfile profile) {
OneContext().showDialog(
builder: (BuildContext context) {
return SimpleDialog(
@@ -220,11 +220,10 @@ class _InvenTreeSelectServerState extends State<InvenTreeSelectServerWidget> {
SimpleDialogOption(
onPressed: () {
Navigator.of(context).pop();
// Navigator.of(context, rootNavigator: true).pop();
confirmationDialog(
L10().delete,
L10().profileDelete + "?",
color: Colors.red,
color: COLOR_DANGER,
icon: TablerIcons.trash,
onAccept: () {
_deleteProfile(profile);
@@ -234,15 +233,53 @@ class _InvenTreeSelectServerState extends State<InvenTreeSelectServerWidget> {
child: ListTile(
title: Text(
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,
appBar: AppBar(
title: Text(L10().profileSelect),
backgroundColor: COLOR_APP_BAR,
actions: [
IconButton(
icon: Icon(TablerIcons.circle_plus),
@@ -305,7 +341,6 @@ class _ProfileEditState extends State<ProfileEditWidget> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: COLOR_APP_BAR,
title: Text(
widget.profile == null ? L10().profileAdd : L10().profileEdit,
),
@@ -415,6 +450,14 @@ class _ProfileEditState extends State<ProfileEditWidget> {
},
),
Divider(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
L10().connectionCheckDetail,
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
+1 -4
View File
@@ -42,10 +42,7 @@ class _InvenTreeSettingsState extends State<InvenTreeSettingsWidget> {
Widget build(BuildContext context) {
return Scaffold(
key: _scaffoldKey,
appBar: AppBar(
title: Text(L10().settings),
backgroundColor: COLOR_APP_BAR,
),
appBar: AppBar(title: Text(L10().settings)),
body: Center(
child: ListView(
children: [
+1 -5
View File
@@ -1,6 +1,5 @@
import "package:flutter/material.dart";
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/l10.dart";
import "package:inventree/preferences.dart";
@@ -44,10 +43,7 @@ class _InvenTreeStockSettingsState extends State<InvenTreeStockSettingsWidget> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(L10().stockSettings),
backgroundColor: COLOR_APP_BAR,
),
appBar: AppBar(title: Text(L10().stockSettings)),
body: Container(
child: ListView(
children: [
+92 -15
View File
@@ -1,3 +1,4 @@
import "package:flutter_secure_storage/flutter_secure_storage.dart";
import "package:sembast/sembast.dart";
import "package:inventree/helpers.dart";
@@ -10,6 +11,7 @@ class UserProfile {
this.server = "",
this.token = "",
this.selected = false,
this.trustedCertificate = false,
});
factory UserProfile.fromJson(
@@ -20,8 +22,11 @@ class UserProfile {
key: key,
name: (json["name"] ?? "") 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,
selected: isSelected,
trustedCertificate: (json["trustedCertificate"] ?? false) as bool,
);
// Return true if this profile has a token
@@ -36,18 +41,23 @@ class UserProfile {
// Base address of the InvenTree 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 = "";
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)
int user_id = -1;
Map<String, dynamic> toJson() => {
"name": name,
"server": server,
"token": token,
"trustedCertificate": trustedCertificate,
};
@override
@@ -62,8 +72,54 @@ class UserProfile {
class UserProfileDBManager {
final store = StoreRef("profiles");
static const _secureStorage = FlutterSecureStorage();
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
*/
@@ -106,6 +162,10 @@ class UserProfileDBManager {
// Record the key
profile.key = key;
if (key != null) {
await _saveToken(key, profile.token);
}
return true;
}
@@ -126,6 +186,7 @@ class UserProfileDBManager {
}
await store.record(profile.key).update(await _db, profile.toJson());
await _saveToken(profile.key!, profile.token);
return true;
}
@@ -137,6 +198,10 @@ class UserProfileDBManager {
debug("deleteProfile: ${profile.name}");
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++) {
if (profiles[idx].key is int && profiles[idx].key == selected) {
return UserProfile.fromJson(
final profile = UserProfile.fromJson(
profiles[idx].key! as int,
profiles[idx].value! as Map<String, dynamic>,
profiles[idx].key == selected,
);
return _loadToken(profile);
}
}
@@ -177,25 +244,41 @@ class UserProfileDBManager {
for (int idx = 0; idx < profiles.length; idx++) {
if (profiles[idx].key is int) {
profileList.add(
UserProfile.fromJson(
final profile = UserProfile.fromJson(
profiles[idx].key! as int,
profiles[idx].value! as Map<String, dynamic>,
profiles[idx].key == selected,
),
);
profileList.add(await _loadToken(profile));
}
}
// If there are no available profiles, create a demo profile
if (profileList.isEmpty) {
return profileList;
}
/*
* Seed a demo server profile, if this is the first time the app has been
* run with zero profiles configured. Explicit (called once from app
* startup) rather than a side effect of reading the profile list.
*/
Future<void> seedDemoProfileIfNeeded() async {
final profiles = await getAllProfiles();
if (profiles.isNotEmpty) {
return;
}
bool added = await InvenTreeSettingsManager().getBool(
"demo_profile_added",
false,
);
// Don't add a new profile if we have added it previously
if (!added) {
if (added) {
return;
}
await InvenTreeSettingsManager().setValue("demo_profile_added", true);
UserProfile demoProfile = UserProfile(
@@ -204,12 +287,6 @@ class UserProfileDBManager {
);
await addProfile(demoProfile);
profileList.add(demoProfile);
}
}
return profileList;
}
/*
+21 -19
View File
@@ -83,7 +83,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
if (showCameraShortcut && widget.order.canEdit) {
actions.add(
SpeedDialChild(
child: const Icon(TablerIcons.camera, color: Colors.blue),
child: Icon(TablerIcons.camera, color: COLOR_ACTION),
label: L10().takePicture,
onTap: () async {
_uploadImage(context);
@@ -98,7 +98,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
if (widget.order.canIssue) {
actions.add(
SpeedDialChild(
child: const Icon(TablerIcons.send, color: Colors.blue),
child: Icon(TablerIcons.send, color: COLOR_ACTION),
label: L10().issueOrder,
onTap: () async {
_issueOrder(context);
@@ -111,7 +111,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
if (widget.order.canCompleteOrder) {
actions.add(
SpeedDialChild(
child: const Icon(TablerIcons.check, color: Colors.green),
child: Icon(TablerIcons.check, color: COLOR_SUCCESS),
label: L10().completeOrder,
onTap: () async {
_completeOrder(context);
@@ -124,7 +124,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
if (widget.order.canHold) {
actions.add(
SpeedDialChild(
child: const Icon(TablerIcons.player_pause, color: Colors.orange),
child: Icon(TablerIcons.player_pause, color: COLOR_WARNING),
label: L10().holdOrder,
onTap: () async {
_holdOrder(context);
@@ -137,9 +137,9 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
if (widget.order.isInProgress) {
actions.add(
SpeedDialChild(
child: const Icon(
child: Icon(
TablerIcons.arrow_autofit_down,
color: Colors.purple,
color: Theme.of(context).colorScheme.secondary,
),
label: L10().allocateAuto,
onTap: () async {
@@ -154,7 +154,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
widget.order.allocatedLineItemCount > 0) {
actions.add(
SpeedDialChild(
child: const Icon(TablerIcons.arrow_autofit_up, color: Colors.red),
child: Icon(TablerIcons.arrow_autofit_up, color: COLOR_DANGER),
label: L10().unallocateStock,
onTap: () async {
_unallocateAll(context);
@@ -167,7 +167,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
if (widget.order.canCancel) {
actions.add(
SpeedDialChild(
child: const Icon(TablerIcons.circle_x, color: Colors.red),
child: Icon(TablerIcons.circle_x, color: COLOR_DANGER),
label: L10().cancelOrder,
onTap: () async {
_cancelOrder(context);
@@ -192,7 +192,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
L10().issueOrder,
L10().issueOrderConfirm,
icon: TablerIcons.send,
color: Colors.blue,
color: COLOR_ACTION,
acceptText: L10().issue,
onAccept: () async {
widget.order.issue().then((dynamic) {
@@ -208,7 +208,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
L10().completeOrder,
L10().completeOrderConfirm,
icon: TablerIcons.check,
color: Colors.green,
color: COLOR_SUCCESS,
acceptText: L10().complete,
onAccept: () async {
widget.order.completeOrder().then((dynamic) {
@@ -224,7 +224,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
L10().holdOrder,
L10().holdOrderConfirm,
icon: TablerIcons.player_pause,
color: Colors.orange,
color: COLOR_WARNING,
acceptText: L10().hold,
onAccept: () async {
widget.order.hold().then((dynamic) {
@@ -240,7 +240,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
L10().cancelOrder,
L10().cancelOrderConfirm,
icon: TablerIcons.circle_x,
color: Colors.red,
color: COLOR_DANGER,
acceptText: L10().cancel,
onAccept: () async {
widget.order.cancel().then((dynamic) {
@@ -256,7 +256,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
L10().allocateAuto,
L10().allocateAutoDetail,
icon: TablerIcons.arrow_autofit_down,
color: Colors.purple,
color: Theme.of(context).colorScheme.secondary,
acceptText: L10().allocate,
onAccept: () async {
widget.order.autoAllocate().then((dynamic) {
@@ -272,7 +272,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
L10().unallocateStock,
L10().buildOrderUnallocateDetail,
icon: TablerIcons.trash,
color: Colors.orange,
color: COLOR_WARNING,
acceptText: L10().unallocate,
onAccept: () async {
widget.order.unallocateAll().then((dynamic) {
@@ -399,7 +399,7 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
height: 32,
child: InvenTreeAPI().getThumbnail(part.thumbnail),
)
: const Icon(TablerIcons.box, color: Colors.blue),
: Icon(TablerIcons.box, color: COLOR_ACTION),
onTap: () {
part.goToDetailPage(context);
},
@@ -419,14 +419,14 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
);
// Progress bar
Color progressColor = Colors.blue;
Color progressColor = COLOR_PROGRESS;
if (widget.order.isComplete) {
progressColor = Colors.green;
progressColor = COLOR_SUCCESS;
} else if (widget.order.targetDate.isNotEmpty &&
DateTime.tryParse(widget.order.targetDate) != null &&
DateTime.tryParse(widget.order.targetDate)!.isBefore(DateTime.now())) {
progressColor = Colors.red;
progressColor = COLOR_DANGER;
}
tiles.add(
@@ -434,7 +434,9 @@ class _BuildOrderDetailState extends RefreshableState<BuildOrderDetailWidget> {
title: LinearProgressIndicator(
value: widget.order.progressPercent / 100.0,
color: progressColor,
backgroundColor: const Color(0xFFEEEEEE),
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
leading: const Icon(TablerIcons.chart_bar),
trailing: Text(
+2 -2
View File
@@ -60,7 +60,7 @@ class _BuildItemDetailWidgetState
// Unallocate button
buttons.add(
SpeedDialChild(
child: const Icon(TablerIcons.minus, color: Colors.red),
child: Icon(TablerIcons.minus, color: COLOR_DANGER),
label: L10().unallocate,
onTap: () async {
_unallocateStock(context);
@@ -99,7 +99,7 @@ class _BuildItemDetailWidgetState
L10().unallocateStock,
L10().unallocateStockConfirm,
icon: TablerIcons.minus,
color: Colors.red,
color: COLOR_DANGER,
acceptText: L10().unallocate,
onAccept: () async {
widget.item.delete().then((result) {
+2 -1
View File
@@ -3,6 +3,7 @@ import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/widget/progress.dart";
import "package:inventree/api.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/l10.dart";
import "package:inventree/inventree/build.dart";
@@ -149,7 +150,7 @@ class BuildOrderListItem extends StatelessWidget {
DateTime.tryParse(
order.targetDate,
)!.isBefore(DateTime.now()))
? Colors.red
? COLOR_DANGER
: null,
),
),
+2 -1
View File
@@ -3,6 +3,7 @@ import "package:flutter_speed_dial/flutter_speed_dial.dart";
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/api.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/l10.dart";
import "package:inventree/inventree/company.dart";
@@ -55,7 +56,7 @@ class _CompanyListWidgetState extends RefreshableState<CompanyListWidget> {
if (InvenTreeAPI().checkPermission("company", "add")) {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.circle_plus, color: Colors.green),
child: Icon(TablerIcons.circle_plus, color: COLOR_SUCCESS),
label: L10().companyAdd,
onTap: () {
_addCompany(context);
+130 -73
View File
@@ -3,11 +3,11 @@ import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:one_context/one_context.dart";
import "package:inventree/api.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/helpers.dart";
import "package:inventree/l10.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
@@ -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
*
* @title = Title to be displayed at the top of the dialog
* @description = Simple string description of error
* @data = Error response (e.g from server)
* @response = Error response (e.g from server)
*/
Future<void> showErrorDialog(
String title, {
String description = "",
APIResponse? response,
IconData icon = TablerIcons.exclamation_circle,
Color? color,
Function? onDismissed,
}) 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()) {
return;
}
final Color dialogColor = color ?? COLOR_DANGER;
OneContext()
.showDialog(
builder: (context) => SimpleDialog(
title: ListTile(title: Text(title), leading: Icon(icon)),
children: children,
builder: (context) => AlertDialog(
icon: Icon(icon, color: dialogColor),
iconColor: dialogColor,
title: Text(title),
content: _buildErrorContent(description, response),
actions: [
TextButton(
child: Text(L10().ok),
onPressed: () {
Navigator.pop(context);
},
),
],
),
)
.then((value) {
@@ -233,18 +301,7 @@ Future<void> showServerError(
description += "\nURL: $url";
showSnackIcon(
title,
success: false,
actionText: L10().details,
onAction: () {
showErrorDialog(
title,
description: description,
icon: TablerIcons.server,
);
},
);
showErrorDialog(title, description: description, icon: TablerIcons.server);
}
/*
+257 -24
View File
@@ -6,6 +6,7 @@ import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/api.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/inventree/build.dart";
import "package:inventree/inventree/part.dart";
import "package:inventree/inventree/update_check.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/refreshable_state.dart";
import "package:inventree/widget/snacks.dart";
import "package:inventree/widget/spinner.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 {
const InvenTreeHomePage({Key? key}) : super(key: key);
@@ -54,6 +142,8 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
// Reload the widget
});
}
_loadOrderCounts();
});
}
@@ -71,6 +161,15 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
// Selected user 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) {
if (!InvenTreeAPI().checkConnection()) return;
@@ -233,6 +332,107 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
as bool;
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 {
@@ -277,7 +477,7 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
child: ListTile(
leading: Icon(
icon,
color: connected && allowed ? COLOR_ACTION : Colors.grey,
color: connected && allowed ? COLOR_ACTION : COLOR_GRAY_LIGHT,
),
title: Text(label),
trailing: trailing,
@@ -362,6 +562,11 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
},
role: "build",
permission: "view",
trailing: buildOrderBadges(
context,
outstandingCount: _buildOutstandingCount,
overdueCount: _buildOverdueCount,
),
),
);
}
@@ -376,6 +581,11 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
callback: () {
_showPurchaseOrders(context);
},
trailing: buildOrderBadges(
context,
outstandingCount: _poOutstandingCount,
overdueCount: _poOverdueCount,
),
),
);
}
@@ -389,6 +599,11 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
callback: () {
_showSalesOrders(context);
},
trailing: buildOrderBadges(
context,
outstandingCount: _soOutstandingCount,
overdueCount: _soOverdueCount,
),
),
);
}
@@ -402,6 +617,7 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
callback: () {
_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
if (homeShowCustomers) {
tiles.add(
@@ -473,7 +673,11 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
} else if (connecting) {
title = L10().serverConnecting;
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(
@@ -523,12 +727,21 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
children: getListTiles(context),
childAspectRatio: aspect,
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,
mainAxisSpacing: 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
Widget build(BuildContext context) {
var connected = InvenTreeAPI().isConnected();
@@ -545,10 +758,28 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
Text(L10().appTitle),
],
),
backgroundColor: COLOR_APP_BAR,
actions: [
IconButton(
icon: Stack(
InkWell(
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: [
Icon(TablerIcons.server),
Positioned(
@@ -571,12 +802,14 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
),
],
),
onPressed: _selectProfile,
],
),
),
),
],
),
drawer: InvenTreeDrawer(context),
body: getBody(context),
body: RefreshIndicator(onRefresh: _onRefresh, child: getBody(context)),
bottomNavigationBar: InvenTreeAPI().isConnected()
? buildBottomAppBar(context, homeKey)
: null,
+2 -1
View File
@@ -2,6 +2,7 @@ import "package:flutter/material.dart";
import "package:flutter_speed_dial/flutter_speed_dial.dart";
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/l10.dart";
import "package:inventree/inventree/model.dart";
import "package:inventree/inventree/purchase_order.dart";
@@ -53,7 +54,7 @@ class _PurchaseOrderExtraLineListWidgetState
if (widget.order.canEdit) {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.circle_plus, color: Colors.green),
child: Icon(TablerIcons.circle_plus, color: COLOR_SUCCESS),
label: L10().lineItemAdd,
onTap: () {
_addLineItem(context);
+1 -1
View File
@@ -67,7 +67,7 @@ class _POLineDetailWidgetState extends RefreshableState<POLineDetailWidget> {
if (!widget.item.isComplete) {
buttons.add(
SpeedDialChild(
child: Icon(TablerIcons.transition_right, color: Colors.blue),
child: Icon(TablerIcons.transition_right, color: COLOR_ACTION),
label: L10().receiveItem,
onTap: () async {
receiveLineItem(context);
+7 -7
View File
@@ -95,7 +95,7 @@ class _PurchaseOrderDetailState
if (showCameraShortcut && widget.order.canEdit) {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.camera, color: Colors.blue),
child: Icon(TablerIcons.camera, color: COLOR_ACTION),
label: L10().takePicture,
onTap: () async {
_uploadImage(context);
@@ -108,7 +108,7 @@ class _PurchaseOrderDetailState
if (widget.order.isPending) {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.circle_plus, color: Colors.green),
child: Icon(TablerIcons.circle_plus, color: COLOR_SUCCESS),
label: L10().lineItemAdd,
onTap: () async {
_addLineItem(context);
@@ -118,7 +118,7 @@ class _PurchaseOrderDetailState
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.send, color: Colors.blue),
child: Icon(TablerIcons.send, color: COLOR_ACTION),
label: L10().issueOrder,
onTap: () async {
_issueOrder(context);
@@ -130,7 +130,7 @@ class _PurchaseOrderDetailState
if (widget.order.isOpen && !widget.order.isPending) {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.circle_check, color: Colors.green),
child: Icon(TablerIcons.circle_check, color: COLOR_SUCCESS),
label: L10().completeOrder,
onTap: () async {
_completeOrder(context);
@@ -142,7 +142,7 @@ class _PurchaseOrderDetailState
if (widget.order.isOpen) {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.circle_x, color: Colors.red),
child: Icon(TablerIcons.circle_x, color: COLOR_DANGER),
label: L10().cancelOrder,
onTap: () async {
_cancelOrder(context);
@@ -193,7 +193,7 @@ class _PurchaseOrderDetailState
L10().issueOrder,
L10().issueOrderConfirm,
icon: TablerIcons.send,
color: Colors.blue,
color: COLOR_ACTION,
acceptText: L10().issue,
onAccept: () async {
widget.order.issueOrder().then((dynamic) {
@@ -227,7 +227,7 @@ class _PurchaseOrderDetailState
L10().cancelOrder,
L10().cancelOrderConfirm,
icon: TablerIcons.circle_x,
color: Colors.red,
color: COLOR_DANGER,
acceptText: L10().cancel,
onAccept: () async {
widget.order.cancelOrder().then((dynamic) {
+7 -7
View File
@@ -127,7 +127,7 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> {
L10().issueOrder,
L10().issueOrderConfirm,
icon: TablerIcons.send,
color: Colors.blue,
color: COLOR_ACTION,
acceptText: L10().issue,
onAccept: () async {
widget.order.issueOrder().then((dynamic) {
@@ -143,7 +143,7 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> {
L10().cancelOrder,
L10().cancelOrderConfirm,
icon: TablerIcons.circle_x,
color: Colors.red,
color: COLOR_DANGER,
acceptText: L10().cancel,
onAccept: () async {
await widget.order.cancelOrder().then((dynamic) {
@@ -160,7 +160,7 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> {
if (showCameraShortcut && widget.order.canEdit) {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.camera, color: Colors.blue),
child: Icon(TablerIcons.camera, color: COLOR_ACTION),
label: L10().takePicture,
onTap: () async {
_uploadImage(context);
@@ -172,7 +172,7 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> {
if (widget.order.isPending) {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.send, color: Colors.blue),
child: Icon(TablerIcons.send, color: COLOR_ACTION),
label: L10().issueOrder,
onTap: () async {
_issueOrder(context);
@@ -184,7 +184,7 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> {
if (widget.order.isOpen) {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.circle_x, color: Colors.red),
child: Icon(TablerIcons.circle_x, color: COLOR_DANGER),
label: L10().cancelOrder,
onTap: () async {
_cancelOrder(context);
@@ -198,7 +198,7 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> {
InvenTreeSOLineItem().canCreate) {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.circle_plus, color: Colors.green),
child: Icon(TablerIcons.circle_plus, color: COLOR_SUCCESS),
label: L10().lineItemAdd,
onTap: () async {
_addLineItem(context);
@@ -208,7 +208,7 @@ class _SalesOrderDetailState extends RefreshableState<SalesOrderDetailWidget> {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.circle_plus, color: Colors.green),
child: Icon(TablerIcons.circle_plus, color: COLOR_SUCCESS),
label: L10().shipmentAdd,
onTap: () async {
_addShipment(context);
+2 -1
View File
@@ -2,6 +2,7 @@ import "package:flutter/material.dart";
import "package:flutter_speed_dial/flutter_speed_dial.dart";
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/l10.dart";
import "package:inventree/inventree/model.dart";
@@ -55,7 +56,7 @@ class _SalesOrderExtraLineListWidgetState
if (widget.order.canEdit) {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.circle_plus, color: Colors.green),
child: Icon(TablerIcons.circle_plus, color: COLOR_SUCCESS),
label: L10().lineItemAdd,
onTap: () {
_addLineItem(context);
+1 -1
View File
@@ -111,7 +111,7 @@ class _SOLineDetailWidgetState extends RefreshableState<SoLineDetailWidget> {
if (order != null && order!.isOpen) {
buttons.add(
SpeedDialChild(
child: Icon(TablerIcons.transition_right, color: Colors.blue),
child: Icon(TablerIcons.transition_right, color: COLOR_ACTION),
label: L10().allocateStock,
onTap: () async {
_allocateStock(context);
+4 -4
View File
@@ -154,7 +154,7 @@ class _SOShipmentDetailWidgetState
if (showCameraShortcut) {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.camera, color: Colors.blue),
child: Icon(TablerIcons.camera, color: COLOR_ACTION),
label: L10().takePicture,
onTap: () async {
_uploadImage(context);
@@ -167,7 +167,7 @@ class _SOShipmentDetailWidgetState
if (!widget.shipment.isChecked && !widget.shipment.isShipped) {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.check, color: Colors.green),
child: Icon(TablerIcons.check, color: COLOR_SUCCESS),
label: L10().shipmentCheck,
onTap: () async {
widget.shipment
@@ -185,7 +185,7 @@ class _SOShipmentDetailWidgetState
if (widget.shipment.isChecked && !widget.shipment.isShipped) {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.x, color: Colors.red),
child: Icon(TablerIcons.x, color: COLOR_DANGER),
label: L10().shipmentUncheck,
onTap: () async {
widget.shipment.update(values: {"checked_by": null}).then((_) {
@@ -201,7 +201,7 @@ class _SOShipmentDetailWidgetState
if (!widget.shipment.isShipped) {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.truck_delivery, color: Colors.green),
child: Icon(TablerIcons.truck_delivery, color: COLOR_SUCCESS),
label: L10().shipmentSend,
onTap: () async {
_sendShipment(context);
+1 -1
View File
@@ -22,7 +22,7 @@ Widget ProgressBar(double value, {double maximum = 1.0}) {
return LinearProgressIndicator(
value: v,
backgroundColor: Colors.grey,
backgroundColor: COLOR_GRAY_LIGHT,
color: v >= 1 ? COLOR_SUCCESS : COLOR_WARNING,
);
}
-1
View File
@@ -56,7 +56,6 @@ mixin BaseWidgetProperties {
centerTitle: false,
bottom: tabs.isEmpty ? null : TabBar(tabs: tabs),
title: Text(getAppBarTitle()),
backgroundColor: COLOR_APP_BAR,
actions: appBarActions(context),
leading: backButton(context, key),
);
+4 -3
View File
@@ -4,6 +4,7 @@ import "package:flutter/material.dart";
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:one_context/one_context.dart";
import "package:inventree/app_colors.dart";
import "package:inventree/helpers.dart";
import "package:inventree/l10.dart";
@@ -33,16 +34,16 @@ void showSnackIcon(
_currentSnackOverlay?.remove();
_currentSnackOverlay = null;
Color backgroundColor = Colors.deepOrange;
Color backgroundColor = COLOR_DANGER;
if (success != null && success == true) {
backgroundColor = Colors.lightGreen;
backgroundColor = COLOR_SUCCESS;
if (icon == null && onAction == null) {
icon = TablerIcons.circle_check;
}
} else if (success != null && success == false) {
backgroundColor = Colors.deepOrange;
backgroundColor = COLOR_DANGER;
if (icon == null && onAction == null) {
icon = TablerIcons.exclamation_circle;
-45
View File
@@ -1,45 +0,0 @@
import "package:flutter/material.dart";
import "package:inventree/app_colors.dart";
class Spinner extends StatefulWidget {
const Spinner({
this.color = COLOR_GRAY_LIGHT,
Key? key,
required this.icon,
this.duration = const Duration(milliseconds: 1800),
}) : super(key: key);
final IconData? icon;
final Duration duration;
final Color color;
@override
_SpinnerState createState() => _SpinnerState();
}
class _SpinnerState extends State<Spinner> with SingleTickerProviderStateMixin {
late AnimationController? _controller;
Widget? _child;
@override
void initState() {
_controller = AnimationController(
vsync: this,
duration: Duration(milliseconds: 2000),
)..repeat();
_child = Icon(widget.icon, color: widget.color);
super.initState();
}
@override
void dispose() {
_controller!.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RotationTransition(turns: _controller!, child: _child);
}
}
+5 -5
View File
@@ -94,7 +94,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
if (!widget.item.isSerialized()) {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.circle_check, color: Colors.blue),
child: Icon(TablerIcons.circle_check, color: COLOR_ACTION),
label: L10().countStock,
onTap: _countStockDialog,
),
@@ -102,7 +102,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.circle_minus, color: Colors.red),
child: Icon(TablerIcons.circle_minus, color: COLOR_DANGER),
label: L10().removeStock,
onTap: _removeStockDialog,
),
@@ -110,7 +110,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.circle_plus, color: Colors.green),
child: Icon(TablerIcons.circle_plus, color: COLOR_SUCCESS),
label: L10().addStock,
onTap: _addStockDialog,
),
@@ -144,7 +144,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
if (widget.item.canDelete) {
actions.add(
SpeedDialChild(
child: Icon(TablerIcons.trash, color: Colors.red),
child: Icon(TablerIcons.trash, color: COLOR_DANGER),
label: L10().stockItemDelete,
onTap: () {
_deleteItem(context);
@@ -322,7 +322,7 @@ class _StockItemDisplayState extends RefreshableState<StockDetailWidget> {
L10().stockItemDelete,
L10().stockItemDeleteConfirm,
icon: TablerIcons.trash,
color: Colors.red,
color: COLOR_DANGER,
acceptText: L10().delete,
onAccept: () async {
final bool result = await widget.item.delete();
@@ -189,7 +189,7 @@ class _StockItemTestResultDisplayState
String _value = "";
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 _attachmentRequired = false;
@@ -214,7 +214,7 @@ class _StockItemTestResultDisplayState
}
if (!_hasResult) {
_icon = Icon(TablerIcons.help_circle, color: Colors.blue);
_icon = Icon(TablerIcons.help_circle, color: COLOR_GRAY_LIGHT);
} else if (_result == true) {
_icon = Icon(TablerIcons.circle_check, color: COLOR_SUCCESS);
} else if (_result == false) {
+52 -4
View File
@@ -5,10 +5,10 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
url: "https://pub.dev"
source: hosted
version: "93.0.0"
version: "91.0.0"
adaptive_theme:
dependency: "direct main"
description:
@@ -21,10 +21,10 @@ packages:
dependency: transitive
description:
name: analyzer
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
url: "https://pub.dev"
source: hosted
version: "10.0.1"
version: "8.4.1"
archive:
dependency: transitive
description:
@@ -459,6 +459,54 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
+1
View File
@@ -27,6 +27,7 @@ dependencies:
flutter_localized_locales: ^2.0.5
flutter_markdown: ^0.6.19 # Rendering markdown
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_tabler_icons: ^1.43.0 # Tabler icons
http: ^1.4.0
+36
View File
@@ -22,6 +22,42 @@ void setupTestEnv() {
.setMockMethodCallHandler(channel, (MethodCall methodCall) async {
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
+145
View File
@@ -0,0 +1,145 @@
import "package:flutter/material.dart";
import "package:flutter_test/flutter_test.dart";
import "package:flutter_tabler_icons/flutter_tabler_icons.dart";
import "package:inventree/widget/home.dart";
void main() {
Future<void> pumpBadge(
WidgetTester tester,
Widget? Function(BuildContext context) buildBadge,
) async {
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
return Scaffold(body: buildBadge(context) ?? const SizedBox());
},
),
),
);
}
group("buildOverdueBadge", () {
testWidgets("renders a badge when there is an overdue count", (
WidgetTester tester,
) async {
await pumpBadge(tester, (context) => buildOverdueBadge(context, 5));
expect(find.text("5"), findsOneWidget);
expect(find.byIcon(TablerIcons.calendar_exclamation), findsOneWidget);
});
testWidgets("renders nothing when the overdue count is zero", (
WidgetTester tester,
) async {
await pumpBadge(tester, (context) => buildOverdueBadge(context, 0));
expect(find.text("0"), findsNothing);
});
testWidgets("renders nothing when the overdue count is not loaded", (
WidgetTester tester,
) async {
await pumpBadge(tester, (context) => buildOverdueBadge(context, null));
expect(find.byType(Container), findsNothing);
});
});
group("buildOutstandingBadge", () {
testWidgets("renders a badge when there is an outstanding count", (
WidgetTester tester,
) async {
await pumpBadge(tester, (context) => buildOutstandingBadge(context, 3));
expect(find.text("3"), findsOneWidget);
expect(find.byIcon(TablerIcons.progress), findsOneWidget);
});
testWidgets("uses a less prominent color than the overdue badge", (
WidgetTester tester,
) async {
late Color outstandingColor;
late Color overdueColor;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
final ColorScheme colors = Theme.of(context).colorScheme;
outstandingColor = colors.secondaryContainer;
overdueColor = colors.errorContainer;
return Scaffold(
body: Column(
children: [
buildOutstandingBadge(context, 3)!,
buildOverdueBadge(context, 3)!,
],
),
);
},
),
),
);
final containers = tester
.widgetList<Container>(find.byType(Container))
.toList();
final Color outstandingBadgeColor =
(containers[0].decoration! as BoxDecoration).color!;
final Color overdueBadgeColor =
(containers[1].decoration! as BoxDecoration).color!;
expect(outstandingBadgeColor, outstandingColor);
expect(overdueBadgeColor, overdueColor);
expect(outstandingBadgeColor, isNot(equals(overdueBadgeColor)));
});
});
group("buildOrderBadges", () {
testWidgets("renders nothing when both counts are empty", (
WidgetTester tester,
) async {
await pumpBadge(
tester,
(context) =>
buildOrderBadges(context, outstandingCount: null, overdueCount: 0),
);
expect(find.byType(Container), findsNothing);
});
testWidgets("renders both badges side by side when both counts are set", (
WidgetTester tester,
) async {
await pumpBadge(
tester,
(context) =>
buildOrderBadges(context, outstandingCount: 7, overdueCount: 2),
);
expect(find.text("7"), findsOneWidget);
expect(find.text("2"), findsOneWidget);
expect(find.byIcon(TablerIcons.progress), findsOneWidget);
expect(find.byIcon(TablerIcons.calendar_exclamation), findsOneWidget);
// One outer Row combining the badges, plus one inner Row per badge
expect(find.byType(Row), findsNWidgets(3));
});
testWidgets("renders only the overdue badge when outstanding is zero", (
WidgetTester tester,
) async {
await pumpBadge(
tester,
(context) =>
buildOrderBadges(context, outstandingCount: 0, overdueCount: 4),
);
expect(find.text("4"), findsOneWidget);
expect(find.byType(Container), findsOneWidget);
});
});
}