2
0
mirror of https://github.com/inventree/inventree-app.git synced 2025-04-28 13:36:50 +00:00

Merge pull request #119 from inventree/notifications

Notifications
This commit is contained in:
Oliver 2022-05-04 12:46:38 +10:00 committed by GitHub
commit 1750f93720
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 327 additions and 48 deletions

View File

@ -230,6 +230,9 @@ class InvenTreeAPI {
int get apiVersion => _apiVersion; int get apiVersion => _apiVersion;
// Notification support requires API v25 or newer
bool get supportsNotifications => isConnected() && apiVersion >= 25;
// Are plugins enabled on the server? // Are plugins enabled on the server?
bool _pluginsEnabled = false; bool _pluginsEnabled = false;
@ -428,6 +431,7 @@ class InvenTreeAPI {
// Return the received token // Return the received token
_token = (data["token"] ?? "") as String; _token = (data["token"] ?? "") as String;
print("Received token - $_token"); print("Received token - $_token");
// Request user role information (async) // Request user role information (async)

View File

@ -0,0 +1,51 @@
import "package:inventree/inventree/model.dart";
/*
* Class representing a "notification"
*/
class InvenTreeNotification extends InvenTreeModel {
InvenTreeNotification() : super();
InvenTreeNotification.fromJson(Map<String, dynamic> json) : super.fromJson(json);
@override
InvenTreeNotification createFromJson(Map<String, dynamic> json) {
return InvenTreeNotification.fromJson(json);
}
@override
String get URL => "notifications/";
@override
Map<String, String> 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<void> dismiss() async {
await api.post(
"${url}read/",
);
}
}

View File

@ -533,7 +533,7 @@ class InvenTreeStockItem extends InvenTreeModel {
Map<String, dynamic> data = {}; Map<String, dynamic> data = {};
// Note: Format of adjustment API was updated in API v14 // Note: Format of adjustment API was updated in API v14
if (InvenTreeAPI().supportModernStockTransactions()) { if (api.supportModernStockTransactions()) {
// Modern (> 14) API // Modern (> 14) API
data = { data = {
"items": [ "items": [
@ -560,7 +560,7 @@ class InvenTreeStockItem extends InvenTreeModel {
} }
// Expected API return code depends on server API version // 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( var response = await api.post(
endpoint, endpoint,

View File

@ -308,6 +308,9 @@
"description": "history" "description": "history"
}, },
"home": "Home",
"@homeScreen": {},
"homeScreen": "Home Screen", "homeScreen": "Home Screen",
"@homeScreen": {}, "@homeScreen": {},
@ -461,6 +464,12 @@
"description": "Notes" "description": "Notes"
}, },
"notifications": "Notifications",
"@notifications": {},
"notificationsEmpty": "No unread notifications",
"@notificationsEmpty": {},
"noResponse": "No Response from Server", "noResponse": "No Response from Server",
"@noResponse": {}, "@noResponse": {},
@ -631,6 +640,9 @@
"quantityPositive": "Quantity must be positive", "quantityPositive": "Quantity must be positive",
"@quantityPositive": {}, "@quantityPositive": {},
"queryEmpty": "Enter search query",
"@queryEmpty": {},
"queryNoResults": "No results for query", "queryNoResults": "No results for query",
"@queryNoResults": {}, "@queryNoResults": {},

View File

@ -12,7 +12,6 @@ Widget backButton(BuildContext context, GlobalKey<ScaffoldState> key) {
onLongPress: () { onLongPress: () {
// Display the menu // Display the menu
key.currentState!.openDrawer(); key.currentState!.openDrawer();
print("hello?");
}, },
child: IconButton( child: IconButton(
icon: BackButtonIcon(), icon: BackButtonIcon(),

View File

@ -174,7 +174,6 @@ class _CategoryDisplayState extends RefreshableState<CategoryDisplayWidget> {
icon: FaIcon(FontAwesomeIcons.shapes), icon: FaIcon(FontAwesomeIcons.shapes),
label: L10().parts, label: L10().parts,
), ),
// TODO - Add the "actions" item back in
BottomNavigationBarItem( BottomNavigationBarItem(
icon: FaIcon(FontAwesomeIcons.wrench), icon: FaIcon(FontAwesomeIcons.wrench),
label: L10().actions label: L10().actions

View File

@ -41,7 +41,7 @@ class InvenTreeDrawer extends StatelessWidget {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => SearchWidget() builder: (context) => SearchWidget(true)
) )
); );
} }

View File

@ -1,24 +1,29 @@
import "dart:async";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:font_awesome_flutter/font_awesome_flutter.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_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/settings/settings.dart";
import "package:inventree/user_profile.dart"; import "package:inventree/user_profile.dart";
import "package:inventree/l10.dart";
import "package:inventree/barcode.dart"; import "package:inventree/inventree/notification.dart";
import "package:inventree/api.dart";
import "package:inventree/settings/login.dart";
import "package:inventree/widget/category_display.dart"; import "package:inventree/widget/category_display.dart";
import "package:inventree/widget/company_list.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/location_display.dart";
import "package:inventree/widget/notifications.dart";
import "package:inventree/widget/part_list.dart"; import "package:inventree/widget/part_list.dart";
import "package:inventree/widget/purchase_order_list.dart"; import "package:inventree/widget/purchase_order_list.dart";
import "package:inventree/widget/search.dart"; import "package:inventree/widget/search.dart";
import "package:inventree/widget/snacks.dart"; import "package:inventree/widget/snacks.dart";
import "package:inventree/widget/drawer.dart";
import "package:inventree/app_settings.dart";
class InvenTreeHomePage extends StatefulWidget { class InvenTreeHomePage extends StatefulWidget {
@ -32,15 +37,29 @@ class InvenTreeHomePage extends StatefulWidget {
class _InvenTreeHomePageState extends State<InvenTreeHomePage> { class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
_InvenTreeHomePageState() : super() { _InvenTreeHomePageState() : super() {
// Load display settings // Load display settings
_loadSettings(); _loadSettings();
// Initially load the profile and attempt server connection // Initially load the profile and attempt server connection
_loadProfile(); _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 homeShowPo = false;
bool homeShowSubscribed = false; bool homeShowSubscribed = false;
bool homeShowManufacturers = false; bool homeShowManufacturers = false;
@ -52,18 +71,6 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
// Selected user profile // Selected user profile
UserProfile? _profile; UserProfile? _profile;
void _search(BuildContext context) {
if (!InvenTreeAPI().checkConnection(context)) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SearchWidget()
)
);
}
void _scan(BuildContext context) { void _scan(BuildContext context) {
if (!InvenTreeAPI().checkConnection(context)) return; if (!InvenTreeAPI().checkConnection(context)) return;
@ -168,6 +175,18 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
setState(() {}); setState(() {});
} }
/*
* Refresh the number of active notifications for this user
*/
Future<void> _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 = ""}) { Widget _listTile(BuildContext context, String label, IconData icon, {Function()? callback, String role = "", String permission = ""}) {
@ -224,16 +243,6 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
} }
)); ));
// Search widget
tiles.add(_listTile(
context,
L10().search,
FontAwesomeIcons.search,
callback: () {
_search(context);
}
));
// Parts // Parts
tiles.add(_listTile( tiles.add(_listTile(
context, context,
@ -327,6 +336,79 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
return tiles; 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<BottomNavigationBarItem> getNavBarItems(BuildContext context) {
List<BottomNavigationBarItem> items = <BottomNavigationBarItem>[
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: <Widget>[
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -345,10 +427,18 @@ class _InvenTreeHomePageState extends State<InvenTreeHomePage> {
], ],
), ),
drawer: InvenTreeDrawer(context), drawer: InvenTreeDrawer(context),
body: ListView( body: getBody(context),
scrollDirection: Axis.vertical, bottomNavigationBar: BottomNavigationBar(
children: getListTiles(context), currentIndex: _tabIndex,
) onTap: (int index) {
setState(() {
_tabIndex = index;
});
_refreshNotifications();
},
items: getNavBarItems(context),
),
); );
} }
} }

View File

@ -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<NotificationWidget> {
_NotificationState() : super();
List<InvenTreeNotification> notifications = [];
@override
AppBar? buildAppBar(BuildContext context) {
// No app bar for the notification widget
return null;
}
@override
Future<void> request (BuildContext context) async {
final results = await InvenTreeNotification().list();
notifications.clear();
for (InvenTreeModel n in results) {
if (n is InvenTreeNotification) {
notifications.add(n);
}
}
}
Future<void> dismissNotification(BuildContext context, InvenTreeNotification notification) async {
await notification.dismiss();
refresh(context);
}
List<Widget> renderNotifications(BuildContext context) {
List<Widget> 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()
)
);
}
}

View File

@ -80,6 +80,14 @@ abstract class RefreshableState<T extends StatefulWidget> extends State<T> {
return null; return null;
} }
AppBar? buildAppBar(BuildContext context) {
return AppBar(
title: Text(getAppBarTitle(context)),
actions: getAppBarActions(context),
leading: backButton(context, refreshableKey),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -88,11 +96,7 @@ abstract class RefreshableState<T extends StatefulWidget> extends State<T> {
return Scaffold( return Scaffold(
key: refreshableKey, key: refreshableKey,
appBar: AppBar( appBar: buildAppBar(context),
title: Text(getAppBarTitle(context)),
actions: getAppBarActions(context),
leading: backButton(context, refreshableKey),
),
drawer: getDrawer(context), drawer: getDrawer(context),
floatingActionButton: getFab(context), floatingActionButton: getFab(context),
body: Builder( body: Builder(

View File

@ -21,16 +21,33 @@ import "package:inventree/widget/location_list.dart";
// Widget for performing database-wide search // Widget for performing database-wide search
class SearchWidget extends StatefulWidget { class SearchWidget extends StatefulWidget {
const SearchWidget(this.hasAppbar);
final bool hasAppbar;
@override @override
_SearchDisplayState createState() => _SearchDisplayState(); _SearchDisplayState createState() => _SearchDisplayState(hasAppbar);
} }
class _SearchDisplayState extends RefreshableState<SearchWidget> { class _SearchDisplayState extends RefreshableState<SearchWidget> {
_SearchDisplayState(this.hasAppBar) : super();
final bool hasAppBar;
@override @override
String getAppBarTitle(BuildContext context) => L10().search; String getAppBarTitle(BuildContext context) => L10().search;
@override
AppBar? buildAppBar(BuildContext context) {
if (hasAppBar) {
return super.buildAppBar(context);
} else {
return null;
}
}
final TextEditingController searchController = TextEditingController(); final TextEditingController searchController = TextEditingController();
Timer? debounceTimer; Timer? debounceTimer;
@ -155,12 +172,15 @@ class _SearchDisplayState extends RefreshableState<SearchWidget> {
child: ListTile( child: ListTile(
title: TextField( title: TextField(
readOnly: false, readOnly: false,
decoration: InputDecoration(
helperText: L10().queryEmpty,
),
controller: searchController, controller: searchController,
onChanged: (String text) { onChanged: (String text) {
onSearchTextChanged(text); onSearchTextChanged(text);
}, },
), ),
leading: IconButton( trailing: IconButton(
icon: FaIcon(FontAwesomeIcons.backspace, color: Colors.red), icon: FaIcon(FontAwesomeIcons.backspace, color: Colors.red),
onPressed: () { onPressed: () {
searchController.clear(); searchController.clear();
@ -315,7 +335,7 @@ class _SearchDisplayState extends RefreshableState<SearchWidget> {
); );
} }
if (results.isEmpty) { if (results.isEmpty && searchController.text.isNotEmpty) {
tiles.add( tiles.add(
ListTile( ListTile(
title: Text(L10().queryNoResults), title: Text(L10().queryNoResults),

View File

@ -28,7 +28,7 @@ class _StockListState extends RefreshableState<StockItemList> {
final Map<String, String> filters; final Map<String, String> filters;
@override @override
String getAppBarTitle(BuildContext context) => L10().purchaseOrders; String getAppBarTitle(BuildContext context) => L10().stockItems;
@override @override
Widget getBody(BuildContext context) { Widget getBody(BuildContext context) {