From 8b8fa5ce89d55401cb3a27b07b3fcfbcc7e4cbf1 Mon Sep 17 00:00:00 2001 From: rzuasti Date: Thu, 4 Jun 2026 18:02:07 -0400 Subject: [PATCH] Add pull-to-refresh to the notifications list The devices list already supported pull-to-refresh; mirror that on the notifications list by wrapping its CustomScrollView in a RefreshIndicator with AlwaysScrollableScrollPhysics. Keep the existing list visible during a refresh (_isLoading = _items.isEmpty) instead of flashing the skeleton, matching the devices list behaviour. Add widget tests covering pull-to-refresh for both lists. Co-Authored-By: Claude Opus 4.8 --- TODO.md | 2 +- frontend/lib/home/notifications_list.dart | 18 ++++---- frontend/test/widget/device_list_test.dart | 26 ++++++++++++ .../test/widget/notifications_list_test.dart | 42 +++++++++++++++++++ 4 files changed, 80 insertions(+), 8 deletions(-) diff --git a/TODO.md b/TODO.md index ca62f77..f346dff 100644 --- a/TODO.md +++ b/TODO.md @@ -24,7 +24,7 @@ ## Frontend -- [ ] Mobile - Implement pull to refresh on the notifications and devices list +- [x] Mobile - Implement pull to refresh on the notifications and devices list - [ ] Mobile - reduce lists length (# of items) so they fit on a phone in one screen (use iPhone latest gen and Google phone latest gen) - [ ] Mobile - In the devices list the filters and sort buttons overlap (dont fit in the screen) - [ ] When changing pages (either list) the items should change to placeholders while loading diff --git a/frontend/lib/home/notifications_list.dart b/frontend/lib/home/notifications_list.dart index fa38c0c..139ae8c 100644 --- a/frontend/lib/home/notifications_list.dart +++ b/frontend/lib/home/notifications_list.dart @@ -126,7 +126,7 @@ class _NotificationsListState extends State final token = CancelToken(); _fetchToken = token; setState(() { - _isLoading = true; + _isLoading = _items.isEmpty; _error = null; }); try { @@ -217,12 +217,16 @@ class _NotificationsListState extends State children: [ _buildNotificationsHeader(context), Expanded( - child: CustomScrollView( - controller: _scrollController, - slivers: [ - ..._buildNotificationSlivers(context), - ...widget.trailingSlivers, - ], + child: RefreshIndicator( + onRefresh: () => _fetchPage(_currentPage), + child: CustomScrollView( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + ..._buildNotificationSlivers(context), + ...widget.trailingSlivers, + ], + ), ), ), ], diff --git a/frontend/test/widget/device_list_test.dart b/frontend/test/widget/device_list_test.dart index b7139bd..f224b18 100644 --- a/frontend/test/widget/device_list_test.dart +++ b/frontend/test/widget/device_list_test.dart @@ -100,4 +100,30 @@ void main() { await tearDownTree(tester); }); + + testWidgets('pulling the list down refetches devices', (tester) async { + // A single reply list whose contents are swapped before the pull, so the + // same route re-serializes fresh data on the refresh request. + final devices = >[ + deviceJson(macAddress: '00:00:00:00:00:01'), + ]; + adapter.onGet('/devices', (server) => server.reply(200, devices)); + + await pumpScreen(tester, const DeviceList()); + await pumpUntilFound(tester, find.byType(DeviceRowWide)); + expect(find.byType(DeviceRowWide), findsOneWidget); + + // The next fetch returns an extra device; a pull-down should pick it up. + devices.add(deviceJson(macAddress: '00:00:00:00:00:02')); + await tester.fling( + find.byType(CustomScrollView), + const Offset(0, 300), + 1000, + ); + await tester.pumpAndSettle(); + + expect(find.byType(DeviceRowWide), findsNWidgets(2)); + + await tearDownTree(tester); + }); } diff --git a/frontend/test/widget/notifications_list_test.dart b/frontend/test/widget/notifications_list_test.dart index 5575734..d259953 100644 --- a/frontend/test/widget/notifications_list_test.dart +++ b/frontend/test/widget/notifications_list_test.dart @@ -105,4 +105,46 @@ void main() { await tester.pumpWidget(const SizedBox()); }); + + testWidgets('pulling the list down refetches notifications', (tester) async { + tester.view.physicalSize = const Size(400, 600); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + + // A single reply list whose contents are swapped before the pull, so the + // same route re-serializes fresh data on the refresh request. + final items = >[ + notificationJson(id: 1, title: 'Before refresh'), + ]; + adapter.onGet( + '/notifications', + (server) => server.reply(200, items), + queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 6}, + ); + + await tester.pumpWidget( + MaterialApp( + theme: gruvboxDarkTheme, + home: const Scaffold(body: NotificationsList()), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('Before refresh'), findsOneWidget); + + // The next fetch returns fresh data; a pull-down should pick it up. + items + ..clear() + ..add(notificationJson(id: 2, title: 'After refresh')); + await tester.fling( + find.byType(CustomScrollView), + const Offset(0, 300), + 1000, + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('After refresh'), findsOneWidget); + expect(find.textContaining('Before refresh'), findsNothing); + + await tester.pumpWidget(const SizedBox()); + }); }