Keep notification cards collapsed when a sibling is removed

SliverList recycles State objects by index, so when an expanded
notification was marked as read and dropped from the filtered list, the
next notification inherited the expanded state. Give each card a
ValueKey(id) so its expand/collapse state is matched by notification,
not by list position.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-04 10:02:57 -04:00
co-authored by Claude Opus 4.8
parent 2a6d480428
commit aa5a29271b
2 changed files with 58 additions and 0 deletions
@@ -309,6 +309,7 @@ class _NotificationsListState extends State<NotificationsList>
itemBuilder: (context, index) {
final item = _items[index];
return NotificationCard(
key: ValueKey(item.id),
item: item,
formatter: formatter,
onSetRead: (read) => _setRead(item, read),
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/home/notifications_list.dart';
import 'package:frontend/theme/gruvbox_theme.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/fixtures.dart';
void main() {
late DioAdapter adapter;
setUp(() async {
adapter = await setUpBackendForTest();
});
testWidgets(
'marking an expanded "New" notification as read does not expand the next',
(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},
);
adapter.onGet('/notifications/1', (server) => server.reply(200, null));
await tester.pumpWidget(
MaterialApp(
theme: gruvboxDarkTheme,
home: const Scaffold(body: NotificationsList()),
),
);
await tester.pumpAndSettle();
// Expand the first notification.
await tester.tap(find.byType(ListTile).first);
await tester.pumpAndSettle();
expect(find.text('Mark as read'), findsOneWidget);
// Mark it as read; it disappears from the "New"-filtered list.
await tester.tap(find.text('Mark as read'));
await tester.pumpAndSettle();
// The first notification is gone and the second stays collapsed: no
// card is expanded, so no action buttons are showing.
expect(find.textContaining('First'), findsNothing);
expect(find.textContaining('Second'), findsWidgets);
expect(find.text('Mark as read'), findsNothing);
// Dispose the widget so its polling timer is cancelled.
await tester.pumpWidget(const SizedBox());
},
);
}