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 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-04 18:02:07 -04:00
co-authored by Claude Opus 4.8
parent 7b8a33606c
commit 8b8fa5ce89
4 changed files with 80 additions and 8 deletions
+11 -7
View File
@@ -126,7 +126,7 @@ class _NotificationsListState extends State<NotificationsList>
final token = CancelToken();
_fetchToken = token;
setState(() {
_isLoading = true;
_isLoading = _items.isEmpty;
_error = null;
});
try {
@@ -217,12 +217,16 @@ class _NotificationsListState extends State<NotificationsList>
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,
],
),
),
),
],
@@ -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 = <Map<String, dynamic>>[
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);
});
}
@@ -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 = <Map<String, dynamic>>[
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());
});
}