diff --git a/lib/api.dart b/lib/api.dart index 879cf038..3abb40a4 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -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()) { - showServerError( - _URL_ME, - L10().serverNotConnected, - L10().serverAuthenticationError, - ); + final UserProfile? expiredProfile = profile; // Invalidate the token - if (profile != null) { - profile!.token = ""; - await UserProfileDBManager().updateProfile(profile!); + 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, + ); } return false; @@ -557,8 +579,9 @@ class InvenTreeAPI { Future fetchToken( UserProfile userProfile, String username, - String password, - ) async { + String password, { + bool showDialog = true, + }) async { debug("Fetching user token from ${userProfile.server}"); profile = userProfile; @@ -609,18 +632,22 @@ class InvenTreeAPI { headers: {HttpHeaders.authorizationHeader: authHeader}, ); + final data = response.asMap(); + // Invalid response if (!response.successful()) { - switch (response.statusCode) { - case 401: - case 403: - showServerError( - apiUrl, - L10().serverAuthenticationError, - L10().invalidUsernamePassword, - ); - default: - showStatusCodeError(apiUrl, response.statusCode); + if (showDialog) { + switch (response.statusCode) { + case 401: + case 403: + showServerError( + apiUrl, + L10().serverAuthenticationError, + L10().invalidUsernamePassword, + ); + default: + showStatusCodeError(apiUrl, response.statusCode); + } } debug("Token request failed: STATUS ${response.statusCode}"); @@ -628,16 +655,17 @@ class InvenTreeAPI { if (response.data != null) { debug("Response data: ${response.data.toString()}"); } - } - - final data = response.asMap(); - - if (!data.containsKey("token")) { - showServerError( - apiUrl, - L10().tokenMissing, - L10().tokenMissingFromResponse, - ); + } 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 @@ -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 _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 _retryAfterCertificateTrust( + String url, + String method, + Uri uri, + Map 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 _promptTrustCertificate(String host) async { + final Completer completer = Completer(); + + 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) { diff --git a/lib/api_form.dart b/lib/api_form.dart index 49838966..e42a24cd 100644 --- a/lib/api_form.dart +++ b/lib/api_form.dart @@ -1612,7 +1612,6 @@ class APIFormWidgetState extends State { return Scaffold( appBar: AppBar( title: Text(widget.title), - backgroundColor: COLOR_APP_BAR, actions: [ IconButton( icon: Icon(widget.icon), diff --git a/lib/app_colors.dart b/lib/app_colors.dart index a22dfbc0..b4f675f4 100644 --- a/lib/app_colors.dart +++ b/lib/app_colors.dart @@ -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; diff --git a/lib/barcode/camera_controller.dart b/lib/barcode/camera_controller.dart index 2baaca0a..94a00720 100644 --- a/lib/barcode/camera_controller.dart +++ b/lib/barcode/camera_controller.dart @@ -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 { diff --git a/lib/barcode/wedge_controller.dart b/lib/barcode/wedge_controller.dart index 5edf0f5c..0d7f86ea 100644 --- a/lib/barcode/wedge_controller.dart +++ b/lib/barcode/wedge_controller.dart @@ -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( diff --git a/lib/inventree/build.dart b/lib/inventree/build.dart index a3dda17e..2d611e54 100644 --- a/lib/inventree/build.dart +++ b/lib/inventree/build.dart @@ -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; } } diff --git a/lib/inventree/status_codes.dart b/lib/inventree/status_codes.dart index 0058fc8a..fb9bcf6f 100644 --- a/lib/inventree/status_codes.dart +++ b/lib/inventree/status_codes.dart @@ -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; } } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 17866aa7..e4a40a8f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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": {}, diff --git a/lib/main.dart b/lib/main.dart index 5db07eeb..35c9b10e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 main() async { @@ -123,6 +124,9 @@ class InvenTreeAppState extends State { 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", "") diff --git a/lib/settings/about.dart b/lib/settings/about.dart index db21810c..b40dac15 100644 --- a/lib/settings/about.dart +++ b/lib/settings/about.dart @@ -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(), ), diff --git a/lib/settings/app_settings.dart b/lib/settings/app_settings.dart index dd6c7327..2403d625 100644 --- a/lib/settings/app_settings.dart +++ b/lib/settings/app_settings.dart @@ -163,10 +163,7 @@ class _InvenTreeAppSettingsState extends State { 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: [ diff --git a/lib/settings/barcode_settings.dart b/lib/settings/barcode_settings.dart index 0d709703..a3fc4282 100644 --- a/lib/settings/barcode_settings.dart +++ b/lib/settings/barcode_settings.dart @@ -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: [ 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: [ diff --git a/lib/settings/home_settings.dart b/lib/settings/home_settings.dart index a4da9d20..e5e891e0 100644 --- a/lib/settings/home_settings.dart +++ b/lib/settings/home_settings.dart @@ -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 { 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: [ diff --git a/lib/settings/login.dart b/lib/settings/login.dart index 2e04d498..e94181a7 100644 --- a/lib/settings/login.dart +++ b/lib/settings/login.dart @@ -44,19 +44,28 @@ class _InvenTreeLoginState extends State { 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, ); - hideLoadingOverlay(); - if (response.successful()) { - // Return to the server selector screen - Navigator.of(context).pop(); + // 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 (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 { return Scaffold( appBar: AppBar( title: Text(L10().login), - backgroundColor: COLOR_APP_BAR, actions: [ IconButton( icon: Icon(TablerIcons.transition_right, color: COLOR_SUCCESS), diff --git a/lib/settings/part_settings.dart b/lib/settings/part_settings.dart index fdb5c45e..2b35cda0 100644 --- a/lib/settings/part_settings.dart +++ b/lib/settings/part_settings.dart @@ -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 { @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: [ diff --git a/lib/settings/purchase_order_settings.dart b/lib/settings/purchase_order_settings.dart index 8b6452c4..f4216dcf 100644 --- a/lib/settings/purchase_order_settings.dart +++ b/lib/settings/purchase_order_settings.dart @@ -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: [ diff --git a/lib/settings/release.dart b/lib/settings/release.dart index 7f8e11d7..f8b644e3 100644 --- a/lib/settings/release.dart +++ b/lib/settings/release.dart @@ -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, diff --git a/lib/settings/sales_order_settings.dart b/lib/settings/sales_order_settings.dart index e009f86b..68fcb5a6 100644 --- a/lib/settings/sales_order_settings.dart +++ b/lib/settings/sales_order_settings.dart @@ -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: [ diff --git a/lib/settings/select_server.dart b/lib/settings/select_server.dart index 1d378556..f0190c76 100644 --- a/lib/settings/select_server.dart +++ b/lib/settings/select_server.dart @@ -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 { }); } + /* + * 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 _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 _doSelectProfile( + BuildContext context, + UserProfile profile, + ) async { // Disconnect InvenTree InvenTreeAPI().disconnectFromServer(); @@ -90,22 +116,13 @@ class _InvenTreeSelectServerState extends State { // 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 { 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,12 +164,86 @@ class _InvenTreeSelectServerState extends State { 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); } } + /* + * Show the profile action menu (Connect / Edit / Logout / Delete). + * Reachable via the always-visible trailing "more" button, or long-press + * as a bonus shortcut. + */ + void _showProfileActions(BuildContext context, UserProfile profile) { + OneContext().showDialog( + builder: (BuildContext context) { + return SimpleDialog( + title: Text(profile.name), + children: [ + Divider(), + SimpleDialogOption( + onPressed: () { + Navigator.of(context).pop(); + _selectProfile(context, profile); + }, + child: ListTile( + title: Text(L10().profileConnect), + leading: Icon(TablerIcons.server), + ), + ), + SimpleDialogOption( + onPressed: () { + Navigator.of(context).pop(); + _editProfile(context, userProfile: profile); + }, + child: ListTile( + title: Text(L10().profileEdit), + leading: Icon(TablerIcons.edit), + ), + ), + SimpleDialogOption( + onPressed: () { + Navigator.of(context).pop(); + _logoutProfile(context, userProfile: profile); + }, + child: ListTile( + title: Text(L10().profileLogout), + leading: Icon(TablerIcons.logout), + ), + ), + Divider(), + SimpleDialogOption( + onPressed: () { + Navigator.of(context).pop(); + confirmationDialog( + L10().delete, + L10().profileDelete + "?", + color: COLOR_DANGER, + icon: TablerIcons.trash, + onAccept: () { + _deleteProfile(profile); + }, + ); + }, + child: ListTile( + title: Text( + L10().profileDelete, + style: TextStyle(color: COLOR_DANGER), + ), + leading: Icon(TablerIcons.trash, color: COLOR_DANGER), + ), + ), + ], + ); + }, + ); + } + @override Widget build(BuildContext context) { List children = []; @@ -175,74 +262,24 @@ class _InvenTreeSelectServerState extends State { leading: profile.hasToken ? Icon(TablerIcons.user_check, color: COLOR_SUCCESS) : Icon(TablerIcons.user_cancel, color: COLOR_WARNING), - trailing: _getProfileIcon(profile), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_getProfileIcon(profile) != null) _getProfileIcon(profile)!, + IconButton( + icon: Icon(Icons.more_vert), + tooltip: L10().actions, + onPressed: () { + _showProfileActions(context, profile); + }, + ), + ], + ), onTap: () { _selectProfile(context, profile); }, onLongPress: () { - OneContext().showDialog( - builder: (BuildContext context) { - return SimpleDialog( - title: Text(profile.name), - children: [ - Divider(), - SimpleDialogOption( - onPressed: () { - Navigator.of(context).pop(); - _selectProfile(context, profile); - }, - child: ListTile( - title: Text(L10().profileConnect), - leading: Icon(TablerIcons.server), - ), - ), - SimpleDialogOption( - onPressed: () { - Navigator.of(context).pop(); - _editProfile(context, userProfile: profile); - }, - child: ListTile( - title: Text(L10().profileEdit), - leading: Icon(TablerIcons.edit), - ), - ), - SimpleDialogOption( - onPressed: () { - Navigator.of(context).pop(); - _logoutProfile(context, userProfile: profile); - }, - child: ListTile( - title: Text(L10().profileLogout), - leading: Icon(TablerIcons.logout), - ), - ), - Divider(), - SimpleDialogOption( - onPressed: () { - Navigator.of(context).pop(); - // Navigator.of(context, rootNavigator: true).pop(); - confirmationDialog( - L10().delete, - L10().profileDelete + "?", - color: Colors.red, - icon: TablerIcons.trash, - onAccept: () { - _deleteProfile(profile); - }, - ); - }, - child: ListTile( - title: Text( - L10().profileDelete, - style: TextStyle(color: Colors.red), - ), - leading: Icon(TablerIcons.trash, color: Colors.red), - ), - ), - ], - ); - }, - ); + _showProfileActions(context, profile); }, ), ); @@ -256,7 +293,6 @@ class _InvenTreeSelectServerState extends State { 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 { 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 { }, ), 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( diff --git a/lib/settings/settings.dart b/lib/settings/settings.dart index 15cd37a9..c1505738 100644 --- a/lib/settings/settings.dart +++ b/lib/settings/settings.dart @@ -42,10 +42,7 @@ class _InvenTreeSettingsState extends State { 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: [ diff --git a/lib/settings/stock_settings.dart b/lib/settings/stock_settings.dart index 3856d238..28332ac5 100644 --- a/lib/settings/stock_settings.dart +++ b/lib/settings/stock_settings.dart @@ -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 { @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: [ diff --git a/lib/user_profile.dart b/lib/user_profile.dart index f53b412a..f6173ea2 100644 --- a/lib/user_profile.dart +++ b/lib/user_profile.dart @@ -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 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 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 _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 _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, profiles[idx].key == selected, ); + + return _loadToken(profile); } } @@ -177,41 +244,51 @@ class UserProfileDBManager { for (int idx = 0; idx < profiles.length; idx++) { if (profiles[idx].key is int) { - profileList.add( - UserProfile.fromJson( - profiles[idx].key! as int, - profiles[idx].value! as Map, - profiles[idx].key == selected, - ), - ); - } - } - - // If there are no available profiles, create a demo profile - if (profileList.isEmpty) { - bool added = await InvenTreeSettingsManager().getBool( - "demo_profile_added", - false, - ); - - // Don't add a new profile if we have added it previously - if (!added) { - await InvenTreeSettingsManager().setValue("demo_profile_added", true); - - UserProfile demoProfile = UserProfile( - name: "InvenTree Demo", - server: "https://demo.inventree.org", + final profile = UserProfile.fromJson( + profiles[idx].key! as int, + profiles[idx].value! as Map, + profiles[idx].key == selected, ); - await addProfile(demoProfile); - - profileList.add(demoProfile); + profileList.add(await _loadToken(profile)); } } 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 seedDemoProfileIfNeeded() async { + final profiles = await getAllProfiles(); + + if (profiles.isNotEmpty) { + return; + } + + bool added = await InvenTreeSettingsManager().getBool( + "demo_profile_added", + false, + ); + + // Don't add a new profile if we have added it previously + if (added) { + return; + } + + await InvenTreeSettingsManager().setValue("demo_profile_added", true); + + UserProfile demoProfile = UserProfile( + name: "InvenTree Demo", + server: "https://demo.inventree.org", + ); + + await addProfile(demoProfile); + } + /* * Retrieve a profile by key (or null if no match exists) */ diff --git a/lib/widget/build/build_detail.dart b/lib/widget/build/build_detail.dart index 5dd2f8cc..9eaf353f 100644 --- a/lib/widget/build/build_detail.dart +++ b/lib/widget/build/build_detail.dart @@ -83,7 +83,7 @@ class _BuildOrderDetailState extends RefreshableState { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { ); // 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 { 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( diff --git a/lib/widget/build/build_item_detail.dart b/lib/widget/build/build_item_detail.dart index 4e60c3c7..a34fefdc 100644 --- a/lib/widget/build/build_item_detail.dart +++ b/lib/widget/build/build_item_detail.dart @@ -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) { diff --git a/lib/widget/build/build_list_item.dart b/lib/widget/build/build_list_item.dart index 3dd2f1be..1c4d6e9a 100644 --- a/lib/widget/build/build_list_item.dart +++ b/lib/widget/build/build_list_item.dart @@ -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, ), ), diff --git a/lib/widget/company/company_list.dart b/lib/widget/company/company_list.dart index 69b851d3..e544db2a 100644 --- a/lib/widget/company/company_list.dart +++ b/lib/widget/company/company_list.dart @@ -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 { 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); diff --git a/lib/widget/dialogs.dart b/lib/widget/dialogs.dart index f8e5407d..ca5c754d 100644 --- a/lib/widget/dialogs.dart +++ b/lib/widget/dialogs.dart @@ -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 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 children = []; + + if (description.isNotEmpty) { + children.add(Text(description)); + } else if (response != null) { + final Map 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 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) { + for (String field in data.keys) { + if ([ + "detail", + "non_field_errors", + "__all__", + "errors", + ].contains(field)) { + continue; + } + + dynamic error = data[field]; + List 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 showErrorDialog( String title, { String description = "", APIResponse? response, IconData icon = TablerIcons.exclamation_circle, + Color? color, Function? onDismissed, }) async { - List 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) { - 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 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); } /* diff --git a/lib/widget/home.dart b/lib/widget/home.dart index 7ed91564..e6d60bc6 100644 --- a/lib/widget/home.dart +++ b/lib/widget/home.dart @@ -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 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 // Reload the widget }); } + + _loadOrderCounts(); }); } @@ -71,6 +161,15 @@ class _InvenTreeHomePageState extends State // 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 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 _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> 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 _loadProfile() async { @@ -277,7 +477,7 @@ class _InvenTreeHomePageState extends State 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 }, role: "build", permission: "view", + trailing: buildOrderBadges( + context, + outstandingCount: _buildOutstandingCount, + overdueCount: _buildOverdueCount, + ), ), ); } @@ -376,6 +581,11 @@ class _InvenTreeHomePageState extends State callback: () { _showPurchaseOrders(context); }, + trailing: buildOrderBadges( + context, + outstandingCount: _poOutstandingCount, + overdueCount: _poOverdueCount, + ), ), ); } @@ -389,6 +599,11 @@ class _InvenTreeHomePageState extends State callback: () { _showSalesOrders(context); }, + trailing: buildOrderBadges( + context, + outstandingCount: _soOutstandingCount, + overdueCount: _soOverdueCount, + ), ), ); } @@ -402,6 +617,7 @@ class _InvenTreeHomePageState extends State callback: () { _showPendingShipments(context); }, + trailing: buildOutstandingBadge(context, _shipmentsPendingCount), ), ); } @@ -420,22 +636,6 @@ class _InvenTreeHomePageState extends State ); } - // 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 } 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 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 _onRefresh() async { + await _loadProfile(); + await _loadOrderCounts(); + } + @override Widget build(BuildContext context) { var connected = InvenTreeAPI().isConnected(); @@ -545,38 +758,58 @@ class _InvenTreeHomePageState extends State Text(L10().appTitle), ], ), - backgroundColor: COLOR_APP_BAR, actions: [ - IconButton( - icon: Stack( - children: [ - Icon(TablerIcons.server), - Positioned( - right: 0, - bottom: 0, - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: connected - ? COLOR_SUCCESS - : (connecting ? COLOR_PROGRESS : COLOR_DANGER), - shape: BoxShape.circle, - border: Border.all( - color: Theme.of(context).scaffoldBackgroundColor, - width: 1.5, + 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( + right: 0, + bottom: 0, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: connected + ? COLOR_SUCCESS + : (connecting ? COLOR_PROGRESS : COLOR_DANGER), + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).scaffoldBackgroundColor, + width: 1.5, + ), + ), + ), + ), + ], ), - ), - ], + ], + ), ), - onPressed: _selectProfile, ), ], ), drawer: InvenTreeDrawer(context), - body: getBody(context), + body: RefreshIndicator(onRefresh: _onRefresh, child: getBody(context)), bottomNavigationBar: InvenTreeAPI().isConnected() ? buildBottomAppBar(context, homeKey) : null, diff --git a/lib/widget/order/po_extra_line_list.dart b/lib/widget/order/po_extra_line_list.dart index b9d953fd..c55d223e 100644 --- a/lib/widget/order/po_extra_line_list.dart +++ b/lib/widget/order/po_extra_line_list.dart @@ -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); diff --git a/lib/widget/order/po_line_detail.dart b/lib/widget/order/po_line_detail.dart index 16bac283..976e7629 100644 --- a/lib/widget/order/po_line_detail.dart +++ b/lib/widget/order/po_line_detail.dart @@ -67,7 +67,7 @@ class _POLineDetailWidgetState extends RefreshableState { 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); diff --git a/lib/widget/order/purchase_order_detail.dart b/lib/widget/order/purchase_order_detail.dart index 306ad6fe..f149034e 100644 --- a/lib/widget/order/purchase_order_detail.dart +++ b/lib/widget/order/purchase_order_detail.dart @@ -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) { diff --git a/lib/widget/order/sales_order_detail.dart b/lib/widget/order/sales_order_detail.dart index 7b3298b5..6a53293c 100644 --- a/lib/widget/order/sales_order_detail.dart +++ b/lib/widget/order/sales_order_detail.dart @@ -127,7 +127,7 @@ class _SalesOrderDetailState extends RefreshableState { 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 { 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 { 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 { 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 { 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 { 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 { 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); diff --git a/lib/widget/order/so_extra_line_list.dart b/lib/widget/order/so_extra_line_list.dart index 06521e6c..a7160b83 100644 --- a/lib/widget/order/so_extra_line_list.dart +++ b/lib/widget/order/so_extra_line_list.dart @@ -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); diff --git a/lib/widget/order/so_line_detail.dart b/lib/widget/order/so_line_detail.dart index bc6fd6fe..d6e2a986 100644 --- a/lib/widget/order/so_line_detail.dart +++ b/lib/widget/order/so_line_detail.dart @@ -111,7 +111,7 @@ class _SOLineDetailWidgetState extends RefreshableState { 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); diff --git a/lib/widget/order/so_shipment_detail.dart b/lib/widget/order/so_shipment_detail.dart index 34984dfd..40a69e89 100644 --- a/lib/widget/order/so_shipment_detail.dart +++ b/lib/widget/order/so_shipment_detail.dart @@ -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); diff --git a/lib/widget/progress.dart b/lib/widget/progress.dart index bb043ad2..7deab485 100644 --- a/lib/widget/progress.dart +++ b/lib/widget/progress.dart @@ -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, ); } diff --git a/lib/widget/refreshable_state.dart b/lib/widget/refreshable_state.dart index d9c5ad59..aa6954cd 100644 --- a/lib/widget/refreshable_state.dart +++ b/lib/widget/refreshable_state.dart @@ -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), ); diff --git a/lib/widget/snacks.dart b/lib/widget/snacks.dart index d0adbf63..f6b0a3d8 100644 --- a/lib/widget/snacks.dart +++ b/lib/widget/snacks.dart @@ -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; diff --git a/lib/widget/spinner.dart b/lib/widget/spinner.dart deleted file mode 100644 index 57abb429..00000000 --- a/lib/widget/spinner.dart +++ /dev/null @@ -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 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); - } -} diff --git a/lib/widget/stock/stock_detail.dart b/lib/widget/stock/stock_detail.dart index d10a38cb..dfdf4762 100644 --- a/lib/widget/stock/stock_detail.dart +++ b/lib/widget/stock/stock_detail.dart @@ -94,7 +94,7 @@ class _StockItemDisplayState extends RefreshableState { 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 { 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 { 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 { 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 { 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(); diff --git a/lib/widget/stock/stock_item_test_results.dart b/lib/widget/stock/stock_item_test_results.dart index 9fb7d926..da696b53 100644 --- a/lib/widget/stock/stock_item_test_results.dart +++ b/lib/widget/stock/stock_item_test_results.dart @@ -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) { diff --git a/pubspec.lock b/pubspec.lock index 9669c08c..dfdb383a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 53602070..a1635ea0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/setup.dart b/test/setup.dart index 10e849bb..994b239f 100644 --- a/test/setup.dart +++ b/test/setup.dart @@ -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 secureStorageMock = {}; + const MethodChannel secureStorageChannel = MethodChannel( + "plugins.it_nomads.com/flutter_secure_storage", + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(secureStorageChannel, ( + MethodCall methodCall, + ) async { + final Map args = + (methodCall.arguments as Map?) ?? {}; + 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 diff --git a/test/widget/home_badge_test.dart b/test/widget/home_badge_test.dart new file mode 100644 index 00000000..c8f63ed5 --- /dev/null +++ b/test/widget/home_badge_test.dart @@ -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 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(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); + }); + }); +}