mirror of
https://github.com/inventree/inventree-app.git
synced 2026-07-21 11:43:31 +00:00
UI updates (#849)
* Updated dashboard - Display "outstanding" badges - Display "overdue" badges - Pull-to-refresh * Remove dead code * Refactor app colors * Add "pending shipments" icon * Remove custom spinner * Refactor error dialog * Updated redirect after login * Refactor login/account pages - Better UX and confirmation messages * Refactor API error messages * Improve token management - Secure storage - Handle session expiry - Per-profile HTTPS certificate checks * Improved error messages
This commit is contained in:
+275
-42
@@ -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,38 +758,58 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage>
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user