diff --git a/lib/api.dart b/lib/api.dart index 71af9cbe..ee8a6132 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -230,6 +230,9 @@ class InvenTreeAPI { int get apiVersion => _apiVersion; + // Notification support requires API v25 or newer + bool get supportsNotifications => isConnected() && apiVersion >= 25; + // Are plugins enabled on the server? bool _pluginsEnabled = false; @@ -428,6 +431,7 @@ class InvenTreeAPI { // Return the received token _token = (data["token"] ?? "") as String; + print("Received token - $_token"); // Request user role information (async) diff --git a/lib/inventree/notification.dart b/lib/inventree/notification.dart new file mode 100644 index 00000000..c6b74049 --- /dev/null +++ b/lib/inventree/notification.dart @@ -0,0 +1,51 @@ +import "package:inventree/inventree/model.dart"; + +/* + * Class representing a "notification" + */ + +class InvenTreeNotification extends InvenTreeModel { + + InvenTreeNotification() : super(); + + InvenTreeNotification.fromJson(Map json) : super.fromJson(json); + + @override + InvenTreeNotification createFromJson(Map json) { + return InvenTreeNotification.fromJson(json); + } + + @override + String get URL => "notifications/"; + + @override + Map defaultListFilters() { + + // By default, only return 'unread' notifications + return { + "read": "false", + }; + } + + String get message => (jsondata["message"] ?? "") as String; + + DateTime? get creationDate { + if (jsondata.containsKey("creation")) { + return DateTime.tryParse((jsondata["creation"] ?? "") as String); + } else { + return null; + } + } + + /* + * Dismiss this notification (mark as read) + */ + Future dismiss() async { + + await api.post( + "${url}read/", + ); + + } + +} \ No newline at end of file diff --git a/lib/inventree/stock.dart b/lib/inventree/stock.dart index 45808d2d..3dc4f2b1 100644 --- a/lib/inventree/stock.dart +++ b/lib/inventree/stock.dart @@ -533,7 +533,7 @@ class InvenTreeStockItem extends InvenTreeModel { Map data = {}; // Note: Format of adjustment API was updated in API v14 - if (InvenTreeAPI().supportModernStockTransactions()) { + if (api.supportModernStockTransactions()) { // Modern (> 14) API data = { "items": [ @@ -560,7 +560,7 @@ class InvenTreeStockItem extends InvenTreeModel { } // Expected API return code depends on server API version - final int expected_response = InvenTreeAPI().supportModernStockTransactions() ? 201 : 200; + final int expected_response = api.supportModernStockTransactions() ? 201 : 200; var response = await api.post( endpoint, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index dc95339c..cf9a2753 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -308,6 +308,9 @@ "description": "history" }, + "home": "Home", + "@homeScreen": {}, + "homeScreen": "Home Screen", "@homeScreen": {}, @@ -461,6 +464,12 @@ "description": "Notes" }, + "notifications": "Notifications", + "@notifications": {}, + + "notificationsEmpty": "No unread notifications", + "@notificationsEmpty": {}, + "noResponse": "No Response from Server", "@noResponse": {}, @@ -631,6 +640,9 @@ "quantityPositive": "Quantity must be positive", "@quantityPositive": {}, + "queryEmpty": "Enter search query", + "@queryEmpty": {}, + "queryNoResults": "No results for query", "@queryNoResults": {}, diff --git a/lib/widget/back.dart b/lib/widget/back.dart index 04f03255..9797e4b7 100644 --- a/lib/widget/back.dart +++ b/lib/widget/back.dart @@ -12,7 +12,6 @@ Widget backButton(BuildContext context, GlobalKey key) { onLongPress: () { // Display the menu key.currentState!.openDrawer(); - print("hello?"); }, child: IconButton( icon: BackButtonIcon(), diff --git a/lib/widget/category_display.dart b/lib/widget/category_display.dart index 8cd5081b..2ce7e02d 100644 --- a/lib/widget/category_display.dart +++ b/lib/widget/category_display.dart @@ -174,7 +174,6 @@ class _CategoryDisplayState extends RefreshableState { icon: FaIcon(FontAwesomeIcons.shapes), label: L10().parts, ), - // TODO - Add the "actions" item back in BottomNavigationBarItem( icon: FaIcon(FontAwesomeIcons.wrench), label: L10().actions diff --git a/lib/widget/drawer.dart b/lib/widget/drawer.dart index 871b5112..85a30259 100644 --- a/lib/widget/drawer.dart +++ b/lib/widget/drawer.dart @@ -41,7 +41,7 @@ class InvenTreeDrawer extends StatelessWidget { Navigator.push( context, MaterialPageRoute( - builder: (context) => SearchWidget() + builder: (context) => SearchWidget(true) ) ); } diff --git a/lib/widget/home.dart b/lib/widget/home.dart index 20349f73..712b4e2e 100644 --- a/lib/widget/home.dart +++ b/lib/widget/home.dart @@ -1,24 +1,29 @@ +import "dart:async"; + import "package:flutter/material.dart"; import "package:font_awesome_flutter/font_awesome_flutter.dart"; +import "package:inventree/api.dart"; import "package:inventree/app_colors.dart"; +import "package:inventree/app_settings.dart"; +import "package:inventree/barcode.dart"; +import "package:inventree/l10.dart"; +import "package:inventree/settings/login.dart"; import "package:inventree/settings/settings.dart"; import "package:inventree/user_profile.dart"; -import "package:inventree/l10.dart"; -import "package:inventree/barcode.dart"; -import "package:inventree/api.dart"; -import "package:inventree/settings/login.dart"; + +import "package:inventree/inventree/notification.dart"; + import "package:inventree/widget/category_display.dart"; import "package:inventree/widget/company_list.dart"; +import "package:inventree/widget/drawer.dart"; import "package:inventree/widget/location_display.dart"; +import "package:inventree/widget/notifications.dart"; import "package:inventree/widget/part_list.dart"; import "package:inventree/widget/purchase_order_list.dart"; import "package:inventree/widget/search.dart"; import "package:inventree/widget/snacks.dart"; -import "package:inventree/widget/drawer.dart"; - -import "package:inventree/app_settings.dart"; class InvenTreeHomePage extends StatefulWidget { @@ -32,15 +37,29 @@ class InvenTreeHomePage extends StatefulWidget { class _InvenTreeHomePageState extends State { _InvenTreeHomePageState() : super() { - // Load display settings _loadSettings(); // Initially load the profile and attempt server connection _loadProfile(); + _refreshNotifications(); + + // Refresh notifications every ~30 seconds + Timer.periodic( + Duration( + milliseconds: 30000, + ), (timer) { + _refreshNotifications(); + }); } + // Index of bottom navigation bar + int _tabIndex = 0; + + // Number of outstanding notifications + int _notificationCounter = 0; + bool homeShowPo = false; bool homeShowSubscribed = false; bool homeShowManufacturers = false; @@ -52,18 +71,6 @@ class _InvenTreeHomePageState extends State { // Selected user profile UserProfile? _profile; - void _search(BuildContext context) { - if (!InvenTreeAPI().checkConnection(context)) return; - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SearchWidget() - ) - ); - - } - void _scan(BuildContext context) { if (!InvenTreeAPI().checkConnection(context)) return; @@ -168,6 +175,18 @@ class _InvenTreeHomePageState extends State { setState(() {}); } + /* + * Refresh the number of active notifications for this user + */ + Future _refreshNotifications() async { + + final notifications = await InvenTreeNotification().list(); + + setState(() { + _notificationCounter = notifications.length; + }); + } + Widget _listTile(BuildContext context, String label, IconData icon, {Function()? callback, String role = "", String permission = ""}) { @@ -224,16 +243,6 @@ class _InvenTreeHomePageState extends State { } )); - // Search widget - tiles.add(_listTile( - context, - L10().search, - FontAwesomeIcons.search, - callback: () { - _search(context); - } - )); - // Parts tiles.add(_listTile( context, @@ -327,6 +336,79 @@ class _InvenTreeHomePageState extends State { return tiles; } + /* + * Return the main body widget for display. + * This depends on the current value of _tabIndex + */ + Widget getBody(BuildContext context) { + switch (_tabIndex) { + case 1: // Search widget + return SearchWidget(false); + case 2: // Notification widget + return NotificationWidget(); + case 0: // Home widget + default: + return ListView( + scrollDirection: Axis.vertical, + children: getListTiles(context), + ); + } + } + + /* + * Construct the bottom navigation bar + */ + List getNavBarItems(BuildContext context) { + + List items = [ + BottomNavigationBarItem( + icon: FaIcon(FontAwesomeIcons.home), + label: L10().home, + ), + BottomNavigationBarItem( + icon: FaIcon(FontAwesomeIcons.search), + label: L10().search, + ), + ]; + + if (InvenTreeAPI().supportsNotifications) { + items.add( + BottomNavigationBarItem( + icon: _notificationCounter == 0 ? FaIcon(FontAwesomeIcons.bell) : Stack( + children: [ + FaIcon(FontAwesomeIcons.bell), + Positioned( + right: 0, + child: Container( + padding: EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(20), + ), + constraints: BoxConstraints( + minWidth: 12, + minHeight: 12, + ), + child: Text( + "${_notificationCounter}", + style: TextStyle( + color: Colors.white, + fontSize: 9, + ), + textAlign: TextAlign.center, + ), + ), + ) + ], + ), + label: L10().notifications, + ) + ); + } + + return items; + } + @override Widget build(BuildContext context) { @@ -345,10 +427,18 @@ class _InvenTreeHomePageState extends State { ], ), drawer: InvenTreeDrawer(context), - body: ListView( - scrollDirection: Axis.vertical, - children: getListTiles(context), - ) + body: getBody(context), + bottomNavigationBar: BottomNavigationBar( + currentIndex: _tabIndex, + onTap: (int index) { + setState(() { + _tabIndex = index; + }); + + _refreshNotifications(); + }, + items: getNavBarItems(context), + ), ); } } diff --git a/lib/widget/notifications.dart b/lib/widget/notifications.dart new file mode 100644 index 00000000..8c03343b --- /dev/null +++ b/lib/widget/notifications.dart @@ -0,0 +1,100 @@ + +import "package:flutter/material.dart"; + +import "package:font_awesome_flutter/font_awesome_flutter.dart"; + +import "package:inventree/l10.dart"; +import "package:inventree/inventree/model.dart"; +import "package:inventree/inventree/notification.dart"; +import "package:inventree/widget/refreshable_state.dart"; + + +class NotificationWidget extends StatefulWidget { + + @override + _NotificationState createState() => _NotificationState(); + +} + + +class _NotificationState extends RefreshableState { + + _NotificationState() : super(); + + List notifications = []; + + @override + AppBar? buildAppBar(BuildContext context) { + // No app bar for the notification widget + return null; + } + + @override + Future request (BuildContext context) async { + + final results = await InvenTreeNotification().list(); + + notifications.clear(); + + for (InvenTreeModel n in results) { + if (n is InvenTreeNotification) { + notifications.add(n); + } + } + } + + Future dismissNotification(BuildContext context, InvenTreeNotification notification) async { + + await notification.dismiss(); + + refresh(context); + + } + + List renderNotifications(BuildContext context) { + + List tiles = []; + + tiles.add( + ListTile( + title: Text( + L10().notifications, + ), + subtitle: notifications.isEmpty ? Text(L10().notificationsEmpty) : null, + leading: notifications.isEmpty ? FaIcon(FontAwesomeIcons.bellSlash) : FaIcon(FontAwesomeIcons.bell), + trailing: Text("${notifications.length}"), + ) + ); + + for (var notification in notifications) { + tiles.add( + ListTile( + title: Text(notification.name), + subtitle: Text(notification.message), + trailing: IconButton( + icon: FaIcon(FontAwesomeIcons.bookmark), + onPressed: () async { + dismissNotification(context, notification); + }, + ), + ) + ); + } + + return tiles; + + } + + @override + Widget getBody(BuildContext context) { + return Center( + child: ListView( + children: ListTile.divideTiles( + context: context, + tiles: renderNotifications(context), + ).toList() + ) + ); + } + +} \ No newline at end of file diff --git a/lib/widget/refreshable_state.dart b/lib/widget/refreshable_state.dart index fe318caa..35185690 100644 --- a/lib/widget/refreshable_state.dart +++ b/lib/widget/refreshable_state.dart @@ -80,6 +80,14 @@ abstract class RefreshableState extends State { return null; } + AppBar? buildAppBar(BuildContext context) { + return AppBar( + title: Text(getAppBarTitle(context)), + actions: getAppBarActions(context), + leading: backButton(context, refreshableKey), + ); + } + @override Widget build(BuildContext context) { @@ -88,11 +96,7 @@ abstract class RefreshableState extends State { return Scaffold( key: refreshableKey, - appBar: AppBar( - title: Text(getAppBarTitle(context)), - actions: getAppBarActions(context), - leading: backButton(context, refreshableKey), - ), + appBar: buildAppBar(context), drawer: getDrawer(context), floatingActionButton: getFab(context), body: Builder( diff --git a/lib/widget/search.dart b/lib/widget/search.dart index 77258d43..2d21fc66 100644 --- a/lib/widget/search.dart +++ b/lib/widget/search.dart @@ -21,16 +21,33 @@ import "package:inventree/widget/location_list.dart"; // Widget for performing database-wide search class SearchWidget extends StatefulWidget { + const SearchWidget(this.hasAppbar); + + final bool hasAppbar; + @override - _SearchDisplayState createState() => _SearchDisplayState(); + _SearchDisplayState createState() => _SearchDisplayState(hasAppbar); } class _SearchDisplayState extends RefreshableState { + _SearchDisplayState(this.hasAppBar) : super(); + + final bool hasAppBar; + @override String getAppBarTitle(BuildContext context) => L10().search; + @override + AppBar? buildAppBar(BuildContext context) { + if (hasAppBar) { + return super.buildAppBar(context); + } else { + return null; + } + } + final TextEditingController searchController = TextEditingController(); Timer? debounceTimer; @@ -155,12 +172,15 @@ class _SearchDisplayState extends RefreshableState { child: ListTile( title: TextField( readOnly: false, + decoration: InputDecoration( + helperText: L10().queryEmpty, + ), controller: searchController, onChanged: (String text) { onSearchTextChanged(text); }, ), - leading: IconButton( + trailing: IconButton( icon: FaIcon(FontAwesomeIcons.backspace, color: Colors.red), onPressed: () { searchController.clear(); @@ -315,7 +335,7 @@ class _SearchDisplayState extends RefreshableState { ); } - if (results.isEmpty) { + if (results.isEmpty && searchController.text.isNotEmpty) { tiles.add( ListTile( title: Text(L10().queryNoResults), diff --git a/lib/widget/stock_list.dart b/lib/widget/stock_list.dart index b78cdf18..09d0dc3a 100644 --- a/lib/widget/stock_list.dart +++ b/lib/widget/stock_list.dart @@ -28,7 +28,7 @@ class _StockListState extends RefreshableState { final Map filters; @override - String getAppBarTitle(BuildContext context) => L10().purchaseOrders; + String getAppBarTitle(BuildContext context) => L10().stockItems; @override Widget getBody(BuildContext context) {