Add "go to last page" and page count to notifications and devices lists

The list endpoints now return a total count alongside the page so the
front-end can show how many pages exist and offer a last-page jump.

Backend: add count(is_new) and count_devices(...) (sharing a WHERE-builder
with list_devices so page and count can't drift), wrap both list responses
in {items, total_count} structs, and register them with utoipa.

Front-end: parse the wrapper shape (dropping the fetch-one-extra trick),
add a Last-page button and a responsive "Page X of Y" / "X / Y" label to
the shared PaginationBar, and track the total in both lists. Notifications
re-sync the count on every fetch and decrement it locally on mark-read/
unread removals so the count stays accurate without a re-fetch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-05 21:09:25 -04:00
co-authored by Claude Opus 4.8
parent 3cb104e364
commit 844d7d189c
21 changed files with 765 additions and 275 deletions
+47 -45
View File
@@ -35,60 +35,62 @@ void main() {
expect(summary.totalRegistered, 7);
});
test('listDevices sends filter + pagination params and trims extra item',
() async {
adapter.onGet(
'/devices',
(server) => server.reply(200, [
deviceJson(macAddress: '00:00:00:00:00:01'),
deviceJson(macAddress: '00:00:00:00:00:02'),
deviceJson(macAddress: '00:00:00:00:00:03'),
]),
queryParameters: {
'is_registered': false,
'device_type': 'laptop',
'sort_by': 'last_seen',
'sort_order': 'asc',
'page_offset': 0,
'page_limit': 3,
},
);
test(
'listDevices sends filter + pagination params and reports the total',
() async {
adapter.onGet(
'/devices',
(server) => server.reply(
200,
pagedListJson([
deviceJson(macAddress: '00:00:00:00:00:01'),
deviceJson(macAddress: '00:00:00:00:00:02'),
], totalCount: 12),
),
queryParameters: {
'is_registered': false,
'device_type': 'laptop',
'sort_by': 'last_seen',
'sort_order': 'asc',
'page_offset': 0,
'page_limit': 2,
},
);
final result = await BackendAPI.instance.listDevices(
isRegistered: false,
deviceType: DeviceType.laptop,
sortBy: 'last_seen',
sortAscending: true,
page: 0,
perPage: 2,
);
final result = await BackendAPI.instance.listDevices(
isRegistered: false,
deviceType: DeviceType.laptop,
sortBy: 'last_seen',
sortAscending: true,
page: 0,
perPage: 2,
);
expect(result.items, hasLength(2));
expect(result.hasNextPage, isTrue);
});
expect(result.items, hasLength(2));
expect(result.totalCount, 12);
},
);
test('listDevices reports no next page when fewer than perPage+1 returned',
() async {
adapter.onGet(
'/devices',
(server) => server.reply(200, [deviceJson()]),
);
test(
'listDevices reports the total when a single page is returned',
() async {
adapter.onGet(
'/devices',
(server) => server.reply(200, pagedListJson([deviceJson()])),
);
final result = await BackendAPI.instance.listDevices(perPage: 2);
final result = await BackendAPI.instance.listDevices(perPage: 2);
expect(result.items, hasLength(1));
expect(result.hasNextPage, isFalse);
});
expect(result.items, hasLength(1));
expect(result.totalCount, 1);
},
);
test('listDevices maps the unknown device type to an empty filter', () async {
adapter.onGet(
'/devices',
(server) => server.reply(200, <dynamic>[]),
queryParameters: {
'device_type': '',
'page_offset': 0,
'page_limit': 11,
},
(server) => server.reply(200, pagedListJson([])),
queryParameters: {'device_type': '', 'page_offset': 0, 'page_limit': 10},
);
final result = await BackendAPI.instance.listDevices(
+41 -46
View File
@@ -13,10 +13,7 @@ void main() {
});
test('markNotificationAsRead GETs the notification path', () async {
adapter.onGet(
'/notifications/7',
(server) => server.reply(200, null),
);
adapter.onGet('/notifications/7', (server) => server.reply(200, null));
await BackendAPI.instance.markNotificationAsRead(7);
});
@@ -39,51 +36,49 @@ void main() {
await BackendAPI.instance.markAllNotificationsAsRead();
});
test('listNotifications sends is_new + pagination and trims extra item',
() async {
adapter.onGet(
'/notifications',
(server) => server.reply(200, [
notificationJson(id: 1),
notificationJson(id: 2),
notificationJson(id: 3),
]),
queryParameters: {
'is_new': true,
'page_offset': 0,
'page_limit': 3,
},
);
test(
'listNotifications sends is_new + pagination and reports the total',
() async {
adapter.onGet(
'/notifications',
(server) => server.reply(
200,
pagedListJson([
notificationJson(id: 1),
notificationJson(id: 2),
], totalCount: 9),
),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 2},
);
final result = await BackendAPI.instance.listNotifications(
true,
page: 0,
perPage: 2,
);
final result = await BackendAPI.instance.listNotifications(
true,
page: 0,
perPage: 2,
);
expect(result.items, hasLength(2));
expect(result.hasNextPage, isTrue);
});
expect(result.items, hasLength(2));
expect(result.totalCount, 9);
},
);
test('listNotifications maps a null filter to an empty is_new param',
() async {
adapter.onGet(
'/notifications',
(server) => server.reply(200, <dynamic>[]),
queryParameters: {
'is_new': '',
'page_offset': 10,
'page_limit': 6,
},
);
test(
'listNotifications maps a null filter to an empty is_new param',
() async {
adapter.onGet(
'/notifications',
(server) => server.reply(200, pagedListJson([])),
queryParameters: {'is_new': '', 'page_offset': 10, 'page_limit': 5},
);
final result = await BackendAPI.instance.listNotifications(
null,
page: 2,
perPage: 5,
);
final result = await BackendAPI.instance.listNotifications(
null,
page: 2,
perPage: 5,
);
expect(result.items, isEmpty);
expect(result.hasNextPage, isFalse);
});
expect(result.items, isEmpty);
expect(result.totalCount, 0);
},
);
}
+8
View File
@@ -74,6 +74,14 @@ Map<String, dynamic> notificationJson({
'mac_address': macAddress,
};
/// Wraps a page of items in the paged-list response shape the list endpoints
/// return: `{items: [...], total_count: N}`. [totalCount] defaults to the page
/// length, so single-page stubs stay terse; pass it to simulate more pages.
Map<String, dynamic> pagedListJson(
List<Map<String, dynamic>> items, {
int? totalCount,
}) => {'items': items, 'total_count': totalCount ?? items.length};
/// Shape shared by the ARP and SNMP scanners (run-on-interval scanners).
Map<String, dynamic> intervalScannerJson({
bool isRunning = false,
+87 -23
View File
@@ -18,10 +18,13 @@ void main() {
testWidgets('renders device rows after loading', (tester) async {
adapter.onGet(
'/devices',
(server) => server.reply(200, [
deviceJson(macAddress: '00:00:00:00:00:01', owner: 'alice'),
deviceJson(macAddress: '00:00:00:00:00:02', owner: 'bob'),
]),
(server) => server.reply(
200,
pagedListJson([
deviceJson(macAddress: '00:00:00:00:00:01', owner: 'alice'),
deviceJson(macAddress: '00:00:00:00:00:02', owner: 'bob'),
]),
),
);
await pumpScreen(tester, const DeviceList());
@@ -35,7 +38,7 @@ void main() {
testWidgets('shows the empty message when there are no devices', (
tester,
) async {
adapter.onGet('/devices', (server) => server.reply(200, <dynamic>[]));
adapter.onGet('/devices', (server) => server.reply(200, pagedListJson([])));
await pumpScreen(tester, const DeviceList());
await pumpUntilFound(tester, find.text('No unregistered devices'));
@@ -63,16 +66,19 @@ void main() {
testWidgets('changing pages scrolls back to the top of the list', (
tester,
) async {
// 11 devices: a full page of 10 plus one, so the pagination bar appears.
// A total of 11 across two pages of 10, so the pagination bar appears.
adapter.onGet(
'/devices',
(server) => server.reply(
200,
List.generate(
11,
(i) => deviceJson(
macAddress: '00:00:00:00:00:${i.toString().padLeft(2, '0')}',
pagedListJson(
List.generate(
10,
(i) => deviceJson(
macAddress: '00:00:00:00:00:${i.toString().padLeft(2, '0')}',
),
),
totalCount: 11,
),
),
);
@@ -107,29 +113,34 @@ void main() {
// Default ordering (last_seen, desc) returns two devices.
adapter.onGet(
'/devices',
(server) => server.reply(200, [
deviceJson(macAddress: '00:00:00:00:00:01'),
deviceJson(macAddress: '00:00:00:00:00:02'),
]),
(server) => server.reply(
200,
pagedListJson([
deviceJson(macAddress: '00:00:00:00:00:01'),
deviceJson(macAddress: '00:00:00:00:00:02'),
]),
),
queryParameters: {
'is_registered': false,
'sort_by': 'last_seen',
'sort_order': 'desc',
'page_offset': 0,
'page_limit': 11,
'page_limit': 10,
},
);
// Sorting by device type (asc) returns a single, distinguishable device.
adapter.onGet(
'/devices',
(server) =>
server.reply(200, [deviceJson(macAddress: '00:00:00:00:00:03')]),
(server) => server.reply(
200,
pagedListJson([deviceJson(macAddress: '00:00:00:00:00:03')]),
),
queryParameters: {
'is_registered': false,
'sort_by': 'device_type',
'sort_order': 'asc',
'page_offset': 0,
'page_limit': 11,
'page_limit': 10,
},
);
@@ -138,9 +149,11 @@ void main() {
expect(find.byType(DeviceRowWide), findsNWidgets(2));
await tester.tap(find.byTooltip('Sort by device type'));
for (var i = 0;
i < 40 && find.byType(DeviceRowWide).evaluate().length != 1;
i++) {
for (
var i = 0;
i < 40 && find.byType(DeviceRowWide).evaluate().length != 1;
i++
) {
await tester.pump(const Duration(milliseconds: 10));
}
expect(find.byType(DeviceRowWide), findsOneWidget);
@@ -149,7 +162,10 @@ void main() {
});
testWidgets('the sort sheet offers ordering by device type', (tester) async {
adapter.onGet('/devices', (server) => server.reply(200, [deviceJson()]));
adapter.onGet(
'/devices',
(server) => server.reply(200, pagedListJson([deviceJson()])),
);
// A phone-width viewport so the compact layout with the sort button shows.
await pumpScreen(tester, const DeviceList(), size: const Size(500, 900));
@@ -169,7 +185,10 @@ void main() {
final devices = <Map<String, dynamic>>[
deviceJson(macAddress: '00:00:00:00:00:01'),
];
adapter.onGet('/devices', (server) => server.reply(200, devices));
adapter.onGet(
'/devices',
(server) => server.reply(200, pagedListJson(devices)),
);
await pumpScreen(tester, const DeviceList());
await pumpUntilFound(tester, find.byType(DeviceRowWide));
@@ -188,4 +207,49 @@ void main() {
await tearDownTree(tester);
});
testWidgets('the last-page button jumps to the final page', (tester) async {
// Wide viewport → page size 10. A total of 25 spans three pages.
List<Map<String, dynamic>> page(int first, int count) => List.generate(
count,
(i) => deviceJson(
macAddress: '00:00:00:00:00:${(first + i).toString().padLeft(2, '0')}',
),
);
adapter.onGet(
'/devices',
(server) => server.reply(200, pagedListJson(page(0, 10), totalCount: 25)),
queryParameters: {
'is_registered': false,
'sort_by': 'last_seen',
'sort_order': 'desc',
'page_offset': 0,
'page_limit': 10,
},
);
adapter.onGet(
'/devices',
(server) => server.reply(200, pagedListJson(page(20, 5), totalCount: 25)),
queryParameters: {
'is_registered': false,
'sort_by': 'last_seen',
'sort_order': 'desc',
'page_offset': 20,
'page_limit': 10,
},
);
// A tall viewport so the full page of rows and the pagination bar are all
// on screen at once (no scrolling needed to reach the last-page button).
await pumpScreen(tester, const DeviceList(), size: const Size(900, 2000));
await pumpUntilFound(tester, find.byType(DeviceRowWide));
expect(find.text('Page 1 of 3'), findsOneWidget);
await tester.tap(find.byTooltip('Last page'));
await pumpUntilFound(tester, find.text('Page 3 of 3'));
expect(find.text('Page 3 of 3'), findsOneWidget);
await tearDownTree(tester);
});
}
+7 -5
View File
@@ -17,9 +17,10 @@ void main() {
void stubHomeEndpoints() {
adapter.onGet(
'/notifications',
(server) => server.reply(200, [
notificationJson(id: 1, title: 'New device found'),
]),
(server) => server.reply(
200,
pagedListJson([notificationJson(id: 1, title: 'New device found')]),
),
);
adapter.onGet(
'/devices/summary',
@@ -47,8 +48,9 @@ void main() {
);
}
testWidgets('shows notifications, the device summary and scanner statuses',
(tester) async {
testWidgets('shows notifications, the device summary and scanner statuses', (
tester,
) async {
stubHomeEndpoints();
// Below the 700px breakpoint the home screen uses its single-column layout,
+130 -35
View File
@@ -20,11 +20,14 @@ void main() {
(tester) async {
adapter.onGet(
'/notifications',
(server) => server.reply(200, [
notificationJson(id: 1, title: 'First', body: 'First body'),
notificationJson(id: 2, title: 'Second', body: 'Second body'),
]),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 6},
(server) => server.reply(
200,
pagedListJson([
notificationJson(id: 1, title: 'First', body: 'First body'),
notificationJson(id: 2, title: 'Second', body: 'Second body'),
]),
),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
);
adapter.onGet('/notifications/1', (server) => server.reply(200, null));
@@ -64,20 +67,20 @@ void main() {
addTearDown(tester.view.resetPhysicalSize);
// The 400px-wide viewport above is a phone, so the list uses the smaller
// phone page size of 4 (requesting page_limit 5 to detect a next page).
// phone page size of 4. A total of 8 spans two pages.
List<Map<String, dynamic>> page(int firstId) => List.generate(
5,
4,
(i) => notificationJson(id: firstId + i, title: 'Item ${firstId + i}'),
);
adapter.onGet(
'/notifications',
(server) => server.reply(200, page(1)),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
(server) => server.reply(200, pagedListJson(page(1), totalCount: 8)),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 4},
);
adapter.onGet(
'/notifications',
(server) => server.reply(200, page(6)),
queryParameters: {'is_new': true, 'page_offset': 4, 'page_limit': 5},
(server) => server.reply(200, pagedListJson(page(5), totalCount: 8)),
queryParameters: {'is_new': true, 'page_offset': 4, 'page_limit': 4},
);
await tester.pumpWidget(
@@ -100,10 +103,11 @@ void main() {
await tester.pumpAndSettle();
expect(position().pixels, greaterThan(0));
// Advance a page: the list should jump back to the top.
// Advance a page: the list should jump back to the top, showing page two's
// first item (id 5).
await tester.tap(find.byTooltip('Next page'));
await tester.pumpAndSettle();
expect(find.textContaining('Item 7'), findsWidgets);
expect(find.textContaining('Item 5'), findsWidgets);
expect(position().pixels, 0);
await tester.pumpWidget(const SizedBox());
@@ -121,8 +125,8 @@ void main() {
];
adapter.onGet(
'/notifications',
(server) => server.reply(200, items),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
(server) => server.reply(200, pagedListJson(items)),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 4},
);
await tester.pumpWidget(
@@ -156,11 +160,14 @@ void main() {
) async {
adapter.onGet(
'/notifications',
(server) => server.reply(200, [
notificationJson(id: 1, title: 'First'),
notificationJson(id: 2, title: 'Second'),
]),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 6},
(server) => server.reply(
200,
pagedListJson([
notificationJson(id: 1, title: 'First'),
notificationJson(id: 2, title: 'Second'),
]),
),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
);
adapter.onGet('/notifications/1', (server) => server.reply(200, null));
@@ -208,8 +215,8 @@ void main() {
];
adapter.onGet(
'/notifications',
(server) => server.reply(200, items),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
(server) => server.reply(200, pagedListJson(items)),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 4},
);
await tester.pumpWidget(
@@ -262,8 +269,8 @@ void main() {
];
adapter.onGet(
'/notifications',
(server) => server.reply(200, items),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
(server) => server.reply(200, pagedListJson(items)),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 4},
);
await tester.pumpWidget(
@@ -295,13 +302,19 @@ void main() {
) async {
adapter.onGet(
'/notifications',
(server) => server.reply(200, [notificationJson(id: 1, title: 'Kept')]),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 6},
(server) => server.reply(
200,
pagedListJson([notificationJson(id: 1, title: 'Kept')]),
),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
);
adapter.onGet(
'/notifications',
(server) => server.reply(200, [notificationJson(id: 1, title: 'Kept')]),
queryParameters: {'is_new': '', 'page_offset': 0, 'page_limit': 6},
(server) => server.reply(
200,
pagedListJson([notificationJson(id: 1, title: 'Kept')]),
),
queryParameters: {'is_new': '', 'page_offset': 0, 'page_limit': 5},
);
adapter.onGet('/notifications/1', (server) => server.reply(200, null));
@@ -336,16 +349,21 @@ void main() {
) async {
adapter.onGet(
'/notifications',
(server) =>
server.reply(200, [notificationJson(id: 1, title: 'New one')]),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 6},
(server) => server.reply(
200,
pagedListJson([notificationJson(id: 1, title: 'New one')]),
),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
);
adapter.onGet(
'/notifications',
(server) => server.reply(200, [
notificationJson(id: 2, title: 'Old one', isNew: false),
]),
queryParameters: {'is_new': false, 'page_offset': 0, 'page_limit': 6},
(server) => server.reply(
200,
pagedListJson([
notificationJson(id: 2, title: 'Old one', isNew: false),
]),
),
queryParameters: {'is_new': false, 'page_offset': 0, 'page_limit': 5},
);
await tester.pumpWidget(
@@ -365,4 +383,81 @@ void main() {
await tester.pumpWidget(const SizedBox());
});
testWidgets('the last-page button jumps to the final page', (tester) async {
// Wide default surface → page size 5. A total of 12 spans three pages.
List<Map<String, dynamic>> page(int firstId, int count) => List.generate(
count,
(i) => notificationJson(id: firstId + i, title: 'Item ${firstId + i}'),
);
adapter.onGet(
'/notifications',
(server) => server.reply(200, pagedListJson(page(1, 5), totalCount: 12)),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
);
adapter.onGet(
'/notifications',
(server) => server.reply(200, pagedListJson(page(11, 2), totalCount: 12)),
queryParameters: {'is_new': true, 'page_offset': 10, 'page_limit': 5},
);
await tester.pumpWidget(
MaterialApp(
theme: gruvboxDarkTheme,
home: const Scaffold(body: NotificationsList()),
),
);
await tester.pumpAndSettle();
expect(find.text('Page 1 of 3'), findsOneWidget);
await tester.tap(find.byTooltip('Last page'));
await tester.pumpAndSettle();
expect(find.text('Page 3 of 3'), findsOneWidget);
expect(find.textContaining('Item 11'), findsWidgets);
await tester.pumpWidget(const SizedBox());
});
testWidgets('marking read under "New" decrements the page total', (
tester,
) async {
// Wide default surface → page size 5. A total of 11 spans three pages;
// dropping one locally should leave ten, i.e. two pages.
adapter.onGet(
'/notifications',
(server) => server.reply(
200,
pagedListJson(
List.generate(
5,
(i) => notificationJson(id: i + 1, title: 'Item ${i + 1}'),
),
totalCount: 11,
),
),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
);
adapter.onGet('/notifications/1', (server) => server.reply(200, null));
await tester.pumpWidget(
MaterialApp(
theme: gruvboxDarkTheme,
home: const Scaffold(body: NotificationsList()),
),
);
await tester.pumpAndSettle();
expect(find.text('Page 1 of 3'), findsOneWidget);
// Mark the first item read; under "New" it leaves the list and the total
// drops by one without a re-fetch.
await tester.tap(find.byType(ListTile).first);
await tester.pumpAndSettle();
await tester.tap(find.text('Mark as read'));
await tester.pumpAndSettle();
expect(find.text('Page 1 of 2'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
});
}
+79 -5
View File
@@ -1,12 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/theme/dimens.dart';
import 'package:frontend/theme/gruvbox_theme.dart';
import 'package:frontend/widgets/pagination_bar.dart';
void main() {
Widget wrap(Widget child) => MaterialApp(
theme: gruvboxDarkTheme,
home: Scaffold(body: child),
Widget wrap(Widget child, {Size? size}) => MediaQuery(
data: MediaQueryData(size: size ?? const Size(1200, 800)),
child: MaterialApp(
theme: gruvboxDarkTheme,
home: Scaffold(body: child),
),
);
testWidgets('disables every navigation button while loading', (tester) async {
@@ -15,7 +19,7 @@ void main() {
wrap(
PaginationBar(
currentPage: 1,
hasNextPage: true,
totalPages: 3,
isLoading: true,
onPageChanged: (page) => changedTo = page,
),
@@ -25,6 +29,7 @@ void main() {
await tester.tap(find.byTooltip('Next page'));
await tester.tap(find.byTooltip('Previous page'));
await tester.tap(find.byTooltip('First page'));
await tester.tap(find.byTooltip('Last page'));
expect(changedTo, -1);
});
@@ -34,7 +39,7 @@ void main() {
wrap(
PaginationBar(
currentPage: 1,
hasNextPage: true,
totalPages: 3,
isLoading: false,
onPageChanged: (page) => changedTo = page,
),
@@ -47,4 +52,73 @@ void main() {
await tester.tap(find.byTooltip('Previous page'));
expect(changedTo, 0);
});
testWidgets('last-page button jumps to the final page', (tester) async {
var changedTo = -1;
await tester.pumpWidget(
wrap(
PaginationBar(
currentPage: 0,
totalPages: 5,
isLoading: false,
onPageChanged: (page) => changedTo = page,
),
),
);
await tester.tap(find.byTooltip('Last page'));
expect(changedTo, 4);
});
testWidgets('forward buttons disable on the last page', (tester) async {
var changedTo = -1;
await tester.pumpWidget(
wrap(
PaginationBar(
currentPage: 4,
totalPages: 5,
isLoading: false,
onPageChanged: (page) => changedTo = page,
),
),
);
await tester.tap(find.byTooltip('Next page'));
await tester.tap(find.byTooltip('Last page'));
expect(changedTo, -1);
});
testWidgets('spells out the page label on wide layouts', (tester) async {
await tester.pumpWidget(
wrap(
const PaginationBar(
currentPage: 1,
totalPages: 5,
isLoading: false,
onPageChanged: _noop,
),
size: const Size(1200, 800),
),
);
expect(find.text('Page 2 of 5'), findsOneWidget);
});
testWidgets('uses the compact page label on narrow layouts', (tester) async {
await tester.pumpWidget(
wrap(
const PaginationBar(
currentPage: 1,
totalPages: 5,
isLoading: false,
onPageChanged: _noop,
),
size: Size(Breakpoints.medium - 1, 800),
),
);
expect(find.text('2 / 5'), findsOneWidget);
});
}
void _noop(int _) {}