diff --git a/backend/run_tests.sh b/backend/run_tests.sh index 0b38b2d..95ab69c 100755 --- a/backend/run_tests.sh +++ b/backend/run_tests.sh @@ -1,3 +1,3 @@ #!/bin/sh -rm -f oott.db -cargo test -- --show-output +sudo rm -f oott.db +sudo cargo test -- --show-output diff --git a/backend/src/db/notifications.rs b/backend/src/db/notifications.rs index aff611e..c985560 100644 --- a/backend/src/db/notifications.rs +++ b/backend/src/db/notifications.rs @@ -1,20 +1,40 @@ use crate::db; use crate::db::error::DbError; use log::{debug, error}; -use rusqlite::params; +use rusqlite::{params, params_from_iter}; use crate::model::notifications::Notification; -pub fn list() -> Result, DbError> { +pub fn list( + page_offset: Option, + page_limit: Option, +) -> Result, DbError> { debug!("Listing notifications"); let conn = db::get_db_connection(); - let mut stmt = conn.prepare( - "SELECT id, created_on, notification_type, title, body FROM notifications WHERE is_new=1", - )?; + let mut sql_statement = + "SELECT id, created_on, notification_type, title, body FROM notifications WHERE is_new=1" + .to_string(); + + sql_statement.push_str(" ORDER BY created_on DESC"); + + let mut params: Vec = Vec::new(); + if page_offset.is_some() && page_limit.is_some() { + debug!( + "Adding paging to list with offset={} and limit={}", + page_offset.unwrap(), + page_limit.unwrap() + ); + sql_statement.push_str(" LIMIT ? OFFSET ?"); + + params.push(page_limit.unwrap().into()); + params.push(page_offset.unwrap().into()); + }; + + let mut stmt = conn.prepare(sql_statement.as_str())?; let notifications: Vec = stmt - .query_map([], |row| { + .query_map(params_from_iter(params.iter()), |row| { Ok(Notification { id: row.get(0)?, created_on: row.get(1)?, @@ -302,7 +322,7 @@ mod tests { #[tokio::test] async fn test_list() { tests_common::setup().await; - let notifications = list().unwrap(); + let notifications = list(None, None).unwrap(); // There should be at least 3 unread notifications assert!( @@ -387,4 +407,33 @@ mod tests { notification5.body ); } + + #[tokio::test] + async fn test_list_pagination() { + tests_common::setup().await; + + // List first page with 2 elements + let first_page = list(Some(0), Some(2)).unwrap(); + + assert_eq!( + first_page.iter().len(), + 2, + "First page should have 2 notifications" + ); + + // List second page with 2 elements, should only be 1 + let second_page = list(Some(2), Some(2)).unwrap(); + assert!( + second_page.iter().len() >= 1, + "Second page should have at least 1 notification" + ); + + // None of the first page elements should be present + for item in first_page.iter() { + assert!( + !second_page.iter().any(|second_item| item == second_item), + "No first page element should be in the second page" + ); + } + } } diff --git a/backend/src/web_server/notifications.rs b/backend/src/web_server/notifications.rs index f871920..7c19480 100644 --- a/backend/src/web_server/notifications.rs +++ b/backend/src/web_server/notifications.rs @@ -1,7 +1,13 @@ -use axum::{Json, extract::Path, http::StatusCode}; +use std::collections::HashMap; + +use axum::{ + Json, + extract::{Path, Query}, + http::StatusCode, +}; use log::error; -use crate::{db, model::notifications::Notification}; +use crate::{db, model::notifications::Notification, web_server::utils}; pub async fn read(Path(id): Path) -> Result, StatusCode> { match db::notifications::mark_as_old(id) { @@ -25,8 +31,13 @@ pub async fn read_without_flagging(Path(id): Path) -> Result Result>, StatusCode> { - match db::notifications::list() { +pub async fn list( + Query(params): Query>, +) -> Result>, StatusCode> { + let page_offset: Option = utils::parse_parameter_int(¶ms, "page_offset"); + let page_limit: Option = utils::parse_parameter_int(¶ms, "page_limit"); + + match db::notifications::list(page_offset, page_limit) { Ok(value) => Ok(Json(value)), Err(err) => { error!("Error listing notifications: {}", err); diff --git a/backend/src/web_server/utils.rs b/backend/src/web_server/utils.rs index 56d1211..fcec721 100644 --- a/backend/src/web_server/utils.rs +++ b/backend/src/web_server/utils.rs @@ -16,6 +16,19 @@ pub fn parse_parameter_bool(params: &HashMap, name: &str) -> Opt } } +pub fn parse_parameter_int(params: &HashMap, name: &str) -> Option { + if params.contains_key(name) { + let param_value = params.get(name).unwrap().as_str(); + debug!("Found parameter {name} with value {}", param_value); + match param_value.parse::() { + Ok(value) => Some(value), + Err(_) => None, + } + } else { + None + } +} + pub fn parse_parameter_string(params: &HashMap, name: &str) -> Option { if params.contains_key(name) { let param_value = params.get(name).unwrap().as_str(); diff --git a/frontend/lib/notifications/notification_list.dart b/frontend/lib/notifications/notification_list.dart index 4fb8611..f2fd091 100644 --- a/frontend/lib/notifications/notification_list.dart +++ b/frontend/lib/notifications/notification_list.dart @@ -1,71 +1,77 @@ import 'package:flutter/material.dart'; -import '../utils/friendly_date_formatter.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; + import '../model/notification.dart' as oott_model; +import '../utils/friendly_date_formatter.dart'; import '../utils/oott_api.dart'; class NotificationList extends StatefulWidget { + const NotificationList({super.key}); + @override _NotificationListState createState() => _NotificationListState(); } class _NotificationListState extends State { - bool _isLoading = true; - List? _events; + final _pagingController = PagingController( + getNextPageKey: (state) => state.lastPageIsEmpty + ? null + : (state.items == null ? 0 : state.items?.length), + fetchPage: (pageKey) => BackendAPI.instance.listNotifications(pageKey), + ); @override - void initState() { - super.initState(); - _getData(); - } - - void _getData() async { - _events = await BackendAPI.instance.listNotifications(); - _isLoading = false; - setState(() {}); + void dispose() { + _pagingController.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Notifications')), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : ListView.separated( - itemCount: _events!.length, - itemBuilder: (context, index) { - return Dismissible( - key: UniqueKey(), - onDismissed: (direction) { - setState(() { - _events!.removeAt(index); - }); + body: PagingListener( + controller: _pagingController, + builder: (context, state, fetchNextPage) => + PagedListView( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) { + return Dismissible( + key: UniqueKey(), + onDismissed: (direction) { + setState(() { + // _events!.removeAt(index); + }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Event marked as read'), - behavior: SnackBarBehavior.floating, - showCloseIcon: true, - ), - ); - }, - background: Container( - color: Theme.of(context).colorScheme.primaryContainer, - alignment: Alignment.center, - child: Icon(Icons.done), - ), - child: ListTile( - leading: Icon(_events![index].notificationType.icon), - title: Text( - '${FriendlyDateFormatter().format(_events![index].createdOn)} - ${_events![index].title}', + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Event marked as read'), + behavior: SnackBarBehavior.floating, + showCloseIcon: true, + ), + ); + }, + background: Container( + color: Theme.of(context).colorScheme.primaryContainer, + alignment: Alignment.center, + child: Icon(Icons.done), ), - subtitle: Text(_events![index].body), - trailing: Icon(Icons.more_vert), - onTap: () {}, - ), - ); - }, - separatorBuilder: (context, index) => Divider(), + child: ListTile( + leading: Icon(item.notificationType.icon), + title: Text( + '${FriendlyDateFormatter().format(item.createdOn)} - ${item.title}', + ), + subtitle: Text(item.body), + trailing: Icon(Icons.more_vert), + onTap: () {}, + ), + ); + }, + ), ), + ), ); } } diff --git a/frontend/lib/settings/settings.dart b/frontend/lib/settings/settings.dart index afe760b..4599cb4 100644 --- a/frontend/lib/settings/settings.dart +++ b/frontend/lib/settings/settings.dart @@ -1,12 +1,13 @@ -import 'dart:io'; - import 'package:encrypter/encrypter/xor.dart'; import 'package:flutter/material.dart'; + import '../utils/oott_api.dart'; import '../utils/pref_utils.dart'; import '../utils/ui_snackbars.dart'; class Settings extends StatefulWidget { + const Settings({super.key}); + @override _SettingsState createState() => _SettingsState(); } diff --git a/frontend/lib/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index 0b40a76..35808ef 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -7,6 +7,7 @@ import '../model/notification.dart'; class BackendAPI { static final BackendAPI _instance = BackendAPI._internal(); + static const _pageSize = 5; static BackendAPI get instance => _instance; @@ -65,11 +66,14 @@ class BackendAPI { late String _apiKey; late Dio _dio; - Future> listNotifications() async { + Future> listNotifications(int offset) async { print('About to call /notifications'); Response response; - response = await _dio.get('/notifications'); + response = await _dio.get( + '/notifications', + queryParameters: {'page_offset': offset, 'page_limit': _pageSize}, + ); print('Received: ' + response.data.toString()); diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index dee6669..9ea4bcb 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -142,6 +142,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_staggered_grid_view: + dependency: transitive + description: + name: flutter_staggered_grid_view + sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395" + url: "https://pub.dev" + source: hosted + version: "0.7.0" flutter_test: dependency: "direct dev" description: flutter @@ -168,6 +176,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + infinite_scroll_pagination: + dependency: "direct main" + description: + name: infinite_scroll_pagination + sha256: b0d28e37cd8f62490ff6aef63f9db93d4c78b7f11b7c6b26f33c69d8476fda78 + url: "https://pub.dev" + source: hosted + version: "5.1.1" intl: dependency: "direct main" description: @@ -389,6 +405,14 @@ packages: description: flutter source: sdk version: "0.0.0" + sliver_tools: + dependency: transitive + description: + name: sliver_tools + sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 + url: "https://pub.dev" + source: hosted + version: "0.2.12" source_span: dependency: transitive description: diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 5c3290f..63be7e0 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: dio: ^5.9.1 encrypter: ^2.0.0 shared_preferences: ^2.5.4 + infinite_scroll_pagination: ^5.1.1 dev_dependencies: flutter_test: