diff --git a/TODO.md b/TODO.md index 1c9ecf7..42a1fdf 100644 --- a/TODO.md +++ b/TODO.md @@ -13,7 +13,7 @@ ## Frontend -- [ ] Change the notifications list so it has explicit paging (not infinite paging) +- [x] Change the notifications list so it has explicit paging (not infinite paging) - [x] List recorded devices - [x] Register a device - [x] Forget a device diff --git a/frontend/lib/home/home_screen.dart b/frontend/lib/home/home_screen.dart index 5481b1f..71c5ac9 100644 --- a/frontend/lib/home/home_screen.dart +++ b/frontend/lib/home/home_screen.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import '../model/notification.dart' as oott_model; import '../utils/friendly_date_formatter.dart'; @@ -12,6 +11,7 @@ import '../widgets/device_summary_card.dart'; import 'notification_card.dart'; const _twoColumnBreakpoint = 700.0; +const _pageSize = 5; enum _NotificationFilter { newOnly('New'), @@ -40,34 +40,49 @@ class _HomeScreenState extends State { _NotificationFilter _filter = _NotificationFilter.newOnly; Timer? _notificationTimer; - late final _pagingController = - PagingController( - getNextPageKey: (state) => state.lastPageIsEmpty - ? null - : (state.items == null ? 0 : state.items?.length), - fetchPage: (pageKey) => - BackendAPI.instance.listNotifications(_filter.isNew, pageKey), - ); + int _currentPage = 0; + List _items = []; + bool _isLoading = false; + bool _hasNextPage = false; @override void initState() { super.initState(); + _fetchPage(0); _notificationTimer = Timer.periodic( const Duration(minutes: 1), - (_) => _pagingController.refresh(), + (_) => _fetchPage(_currentPage), ); } @override void dispose() { _notificationTimer?.cancel(); - _pagingController.dispose(); super.dispose(); } + Future _fetchPage(int page) async { + if (_isLoading) return; + setState(() => _isLoading = true); + try { + final results = await BackendAPI.instance + .listNotifications(_filter.isNew, page * _pageSize, limit: _pageSize + 1); + if (!mounted) return; + setState(() { + _currentPage = page; + _hasNextPage = results.length > _pageSize; + _items = _hasNextPage ? results.take(_pageSize).toList() : results; + _isLoading = false; + }); + } catch (_) { + if (!mounted) return; + setState(() => _isLoading = false); + } + } + Future _markAllAsRead(BuildContext context) async { await BackendAPI.instance.markAllNotificationsAsRead(); - _pagingController.refresh(); + _fetchPage(_currentPage); if (context.mounted) { UISnackbars.showSuccess(context, 'All notifications marked as read'); } @@ -96,13 +111,13 @@ class _HomeScreenState extends State { 'Event marked as ${read ? 'read' : 'unread'}', ); if (_filter != _NotificationFilter.all) { - _pagingController.value = _pagingController.value.filterItems( - (n) => n.id != item.id, - ); + setState(() => _items = _items.where((n) => n.id != item.id).toList()); return true; } - _pagingController.mapItems( - (n) => n.id == item.id ? n.copyWith(isNew: !read) : n, + setState( + () => _items = _items + .map((n) => n.id == item.id ? n.copyWith(isNew: !read) : n) + .toList(), ); return false; } @@ -112,21 +127,14 @@ class _HomeScreenState extends State { return LayoutBuilder( builder: (context, constraints) { final isTwoColumn = constraints.maxWidth >= _twoColumnBreakpoint; - return PagingListener( - controller: _pagingController, - builder: (context, state, fetchNextPage) => isTwoColumn - ? _buildTwoColumn(context, state, fetchNextPage) - : _buildSingleColumn(context, state, fetchNextPage), - ); + return isTwoColumn + ? _buildTwoColumn(context) + : _buildSingleColumn(context); }, ); } - Widget _buildTwoColumn( - BuildContext context, - PagingState state, - void Function() fetchNextPage, - ) { + Widget _buildTwoColumn(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -135,10 +143,12 @@ class _HomeScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildNotificationsHeader(context, state), + _buildNotificationsHeader(context), Expanded( child: CustomScrollView( - slivers: [_buildNotificationSliver(state, fetchNextPage)], + slivers: [ + ..._buildNotificationSlivers(context), + ], ), ), ], @@ -162,32 +172,11 @@ class _HomeScreenState extends State { ); } - Widget _buildSingleColumn( - BuildContext context, - PagingState state, - void Function() fetchNextPage, - ) { - final isEmpty = - !state.isLoading && state.items != null && state.items!.isEmpty; + Widget _buildSingleColumn(BuildContext context) { return CustomScrollView( slivers: [ - SliverToBoxAdapter(child: _buildNotificationsHeader(context, state)), - if (isEmpty) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(top: 16, bottom: 12), - child: Center( - child: Text( - 'No items found', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ), - ) - else - _buildNotificationSliver(state, fetchNextPage), + SliverToBoxAdapter(child: _buildNotificationsHeader(context)), + ..._buildNotificationSlivers(context), const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.only(top: 24), @@ -204,10 +193,38 @@ class _HomeScreenState extends State { ); } - Widget _buildNotificationsHeader( - BuildContext context, - PagingState state, - ) { + List _buildNotificationSlivers(BuildContext context) { + if (_isLoading) { + return [ + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ), + ]; + } + if (_items.isEmpty) { + return [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(top: 16, bottom: 12), + child: Center( + child: Text( + 'No items found', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ]; + } + return [ + _buildNotificationSliver(), + if (_currentPage > 0 || _hasNextPage) _buildPaginationControls(context), + ]; + } + + Widget _buildNotificationsHeader(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -218,8 +235,7 @@ class _HomeScreenState extends State { style: Theme.of(context).textTheme.titleLarge, ), const Spacer(), - if (_filter == _NotificationFilter.newOnly && - (state.items?.isNotEmpty ?? false)) + if (_filter == _NotificationFilter.newOnly && _items.isNotEmpty) IconButton( onPressed: () => _markAllAsRead(context), icon: const Icon(Icons.done_all), @@ -239,7 +255,7 @@ class _HomeScreenState extends State { selected: _filter == f, onSelected: (_) { setState(() => _filter = f); - _pagingController.refresh(); + _fetchPage(0); }, ), ) @@ -250,19 +266,50 @@ class _HomeScreenState extends State { ); } - Widget _buildNotificationSliver( - PagingState state, - void Function() fetchNextPage, - ) { + Widget _buildNotificationSliver() { final formatter = FriendlyDateFormatter(); - return PagedSliverList( - state: state, - fetchNextPage: fetchNextPage, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => NotificationCard( + return SliverList.builder( + itemCount: _items.length, + itemBuilder: (context, index) { + final item = _items[index]; + return NotificationCard( item: item, formatter: formatter, onSetRead: (ctx, read) => _setRead(ctx, item, read), + ); + }, + ); + } + + Widget _buildPaginationControls(BuildContext context) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton.outlined( + onPressed: _currentPage > 0 && !_isLoading + ? () => _fetchPage(_currentPage - 1) + : null, + icon: const Icon(Icons.chevron_left), + tooltip: 'Previous page', + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Page ${_currentPage + 1}', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + IconButton.outlined( + onPressed: _hasNextPage && !_isLoading + ? () => _fetchPage(_currentPage + 1) + : null, + icon: const Icon(Icons.chevron_right), + tooltip: 'Next page', + ), + ], ), ), ); diff --git a/frontend/lib/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index dc2b3b4..9153f49 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -172,7 +172,11 @@ class BackendAPI { return ArpScannerStatus.fromJson(response.data as Map); } - Future> listNotifications(bool? isNew, int offset) async { + Future> listNotifications( + bool? isNew, + int offset, { + int? limit, + }) async { debugPrint('About to call /notifications'); Response response; @@ -181,7 +185,7 @@ class BackendAPI { queryParameters: { 'is_new': isNew ?? '', 'page_offset': offset, - 'page_limit': _pageSize, + 'page_limit': limit ?? _pageSize, }, ); diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index fdf92f4..36754e2 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -166,14 +166,6 @@ 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 @@ -224,14 +216,6 @@ 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: @@ -525,14 +509,6 @@ 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 760572f..d741398 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -15,7 +15,6 @@ dependencies: dio: ^5.9.1 encrypter: ^2.0.0 shared_preferences: ^2.5.4 - infinite_scroll_pagination: ^5.1.1 fl_chart: ^1.2.0 google_fonts: ^6.2.1 url_launcher: ^6.3.0