Animate notification list inserts, removals and arrivals

Replace the notifications list's wholesale redraw with a SliverAnimatedList
driven by a GlobalKey, keeping `_items` in lockstep with the animated state.

- Background refreshes (poll, pull-to-refresh, resume, route pop, mark-all)
  reconcile against the fetched page: departed rows slide out, newly fetched
  rows slide in at the top with a theme-coloured arrival highlight, and
  surviving rows stay put (with in-place read-state recolouring under "All").
- Filter/page changes and the initial load reset the list (fresh key) so the
  new dataset appears instantly without per-row animation.
- Read/unread removals are owned by the list: buttons play a slide/fade exit,
  while swipes let Dismissible animate and then reconcile, avoiding double
  animation and the disposed-widget race.

Add the arrival highlight overlay to NotificationCard and cover the new
behaviour with widget tests (swipe-out, flash-in, external removal, in-place
"All" mark, filter reset).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-04 19:35:18 -04:00
co-authored by Claude Opus 4.8
parent c949deedb6
commit f847b60c21
4 changed files with 495 additions and 73 deletions
+108 -48
View File
@@ -2,18 +2,38 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../model/notification.dart' as oott_model;
import '../theme/app_colors.dart';
import '../theme/dimens.dart';
import '../utils/friendly_date_formatter.dart';
// How long the arrival highlight takes to fade out.
const _flashDuration = Duration(milliseconds: 900);
class NotificationCard extends StatefulWidget {
final oott_model.Notification item;
final FriendlyDateFormatter formatter;
final Future<bool> Function(bool read) onSetRead;
/// When true, the card briefly tints itself to draw attention to a freshly
/// arrived notification, then fades the tint out.
final bool flash;
/// Called once the arrival highlight has finished fading.
final VoidCallback? onFlashComplete;
/// Asks the owner to drop this item from its list once it has left the
/// current filter. [animated] is true for button-driven removals (the owner
/// plays its own exit animation) and false for swipes, where [Dismissible]
/// has already animated the card away and the owner only needs to reconcile.
final void Function({required bool animated})? onRemove;
const NotificationCard({
required this.item,
required this.formatter,
required this.onSetRead,
this.flash = false,
this.onFlashComplete,
this.onRemove,
super.key,
});
@@ -31,7 +51,10 @@ class _NotificationCardState extends State<NotificationCard> {
alignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => widget.onSetRead(widget.item.isNew),
onPressed: () async {
final left = await widget.onSetRead(widget.item.isNew);
if (left) widget.onRemove?.call(animated: true);
},
child: Text(widget.item.isNew ? 'Mark as read' : 'Mark as unread'),
),
if (widget.item.macAddress != null)
@@ -39,7 +62,10 @@ class _NotificationCardState extends State<NotificationCard> {
icon: const Icon(Icons.open_in_new),
label: const Text('View device'),
onPressed: () async {
if (widget.item.isNew) await widget.onSetRead(true);
if (widget.item.isNew) {
final left = await widget.onSetRead(true);
if (left) widget.onRemove?.call(animated: true);
}
if (context.mounted) {
context.push('/devices/${widget.item.macAddress}');
}
@@ -55,55 +81,89 @@ class _NotificationCardState extends State<NotificationCard> {
final theme = Theme.of(context);
return Card(
color: widget.item.isNew ? theme.colorScheme.secondaryContainer : null,
child: Dismissible(
key: ValueKey(widget.item.id),
confirmDismiss: (direction) =>
widget.onSetRead(direction != DismissDirection.startToEnd),
background: Container(
color: theme.colorScheme.tertiaryContainer,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: Insets.lg),
child: const Icon(Icons.mark_email_unread),
// Clip so the arrival highlight overlay respects the rounded corners.
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
_buildDismissible(context, theme),
if (widget.flash) _buildFlashOverlay(theme),
],
),
);
}
// A translucent tint over the card that fades to transparent once, signalling
// a freshly arrived notification. Uses the theme's info accent, never a
// hardcoded colour.
Widget _buildFlashOverlay(ThemeData theme) {
final tint = theme.extension<AppColorExtension>()!.info;
return Positioned.fill(
child: IgnorePointer(
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 1.0, end: 0.0),
duration: _flashDuration,
onEnd: widget.onFlashComplete,
builder: (context, t, _) =>
ColoredBox(color: tint.withValues(alpha: 0.3 * t)),
),
secondaryBackground: Container(
color: theme.colorScheme.primaryContainer,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: Insets.lg),
child: const Icon(Icons.done),
),
child: InkWell(
onTap: () => setState(() => _expanded = !_expanded),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
leading: Icon(
widget.item.notificationType.icon,
color: widget.item.isNew ? theme.colorScheme.primary : null,
),
title: Text(
'${widget.formatter.format(widget.item.createdOn)} - ${widget.item.title}',
style: widget.item.isNew
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
subtitle: Text(
widget.item.body,
maxLines: _expanded ? null : 2,
overflow: _expanded
? TextOverflow.visible
: TextOverflow.ellipsis,
),
),
);
}
Widget _buildDismissible(BuildContext context, ThemeData theme) {
return Dismissible(
key: ValueKey(widget.item.id),
// Toggle read state on the backend; only let the card slide away when the
// toggle succeeds and the item should leave the current filter. Returning
// false snaps the card back (e.g. the "All" filter keeps the item).
confirmDismiss: (direction) =>
widget.onSetRead(direction != DismissDirection.startToEnd),
// The swipe-out animation is already done; the owner just reconciles.
onDismissed: (_) => widget.onRemove?.call(animated: false),
background: Container(
color: theme.colorScheme.tertiaryContainer,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: Insets.lg),
child: const Icon(Icons.mark_email_unread),
),
secondaryBackground: Container(
color: theme.colorScheme.primaryContainer,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: Insets.lg),
child: const Icon(Icons.done),
),
child: InkWell(
onTap: () => setState(() => _expanded = !_expanded),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
leading: Icon(
widget.item.notificationType.icon,
color: widget.item.isNew ? theme.colorScheme.primary : null,
),
AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
child: _expanded
? _buildActions(context)
: const SizedBox.shrink(),
title: Text(
'${widget.formatter.format(widget.item.createdOn)} - ${widget.item.title}',
style: widget.item.isNew
? const TextStyle(fontWeight: FontWeight.bold)
: null,
),
],
),
subtitle: Text(
widget.item.body,
maxLines: _expanded ? null : 2,
overflow: _expanded
? TextOverflow.visible
: TextOverflow.ellipsis,
),
),
AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
child: _expanded
? _buildActions(context)
: const SizedBox.shrink(),
),
],
),
),
);
+170 -24
View File
@@ -22,6 +22,10 @@ import 'notification_card.dart';
const _phonePageSize = 4;
const _widePageSize = 5;
// Durations for the list's enter/exit animations.
const _insertDuration = Duration(milliseconds: 300);
const _removeDuration = Duration(milliseconds: 250);
enum _NotificationFilter {
newOnly('New'),
oldOnly('Old'),
@@ -58,6 +62,14 @@ class _NotificationsListState extends State<NotificationsList>
bool _isLoading = false;
bool _isPaging = false;
// Drives the animated list. Recreated on every reset (filter/page change,
// initial load) so the new dataset mounts fresh without per-row animations;
// kept stable across background refreshes so inserts/removals animate.
GlobalKey<SliverAnimatedListState> _listKey = GlobalKey();
// Ids of freshly arrived items that should play their highlight on next build.
final Set<int> _flashIds = {};
final FriendlyDateFormatter _formatter = FriendlyDateFormatter();
int get _pageSize => MediaQuery.sizeOf(context).width < Breakpoints.medium
? _phonePageSize
: _widePageSize;
@@ -96,7 +108,7 @@ class _NotificationsListState extends State<NotificationsList>
@override
void didPopNext() {
_fetchPage(_currentPage);
_fetchPage(_currentPage, animateDiff: true);
_startTimer();
}
@@ -106,7 +118,7 @@ class _NotificationsListState extends State<NotificationsList>
_notificationTimer?.cancel();
_notificationTimer = null;
} else if (state == AppLifecycleState.resumed) {
_fetchPage(_currentPage);
_fetchPage(_currentPage, animateDiff: true);
_startTimer();
}
}
@@ -115,7 +127,7 @@ class _NotificationsListState extends State<NotificationsList>
_notificationTimer?.cancel();
_notificationTimer = Timer.periodic(
const Duration(minutes: 1),
(_) => _fetchPage(_currentPage),
(_) => _fetchPage(_currentPage, animateDiff: true),
);
}
@@ -136,10 +148,16 @@ class _NotificationsListState extends State<NotificationsList>
/// When [paging] is set the current page stays visible and the pagination
/// bar shows a progress cue; background refreshes leave [paging] false so
/// they don't flash the bar.
///
/// When [animateDiff] is set (background refreshes), the new data is merged
/// into the existing list so arrivals and departures animate. Otherwise the
/// fetch is a reset (initial load, filter or page change): the list is
/// rebuilt wholesale with no per-row animation.
Future<void> _fetchPage(
int page, {
bool scrollToTop = false,
bool paging = false,
bool animateDiff = false,
}) async {
if (scrollToTop && _scrollController.hasClients) {
_scrollController.animateTo(
@@ -165,14 +183,28 @@ class _NotificationsListState extends State<NotificationsList>
cancelToken: token,
);
if (!mounted || token != _fetchToken) return;
paginationLoading.value = false;
if (animateDiff && !_isLoading) {
// Merge into the live list so changes animate. Scalars are updated
// without setState here; _reconcile schedules the rebuild itself so it
// can defer swapping in the empty state until exit animations finish.
_currentPage = page;
_hasNextPage = result.hasNextPage;
_isLoading = false;
_isPaging = false;
_reconcile(result.items);
return;
}
// Reset: a fresh dataset. Recreate the list key so the animated list
// mounts anew and shows the rows immediately, without insert animations.
setState(() {
_listKey = GlobalKey();
_currentPage = page;
_hasNextPage = result.hasNextPage;
_items = result.items;
_isLoading = false;
_isPaging = false;
});
paginationLoading.value = false;
} catch (e) {
if (!mounted || token != _fetchToken) return;
if (e is DioException && e.type == DioExceptionType.cancel) return;
@@ -194,10 +226,15 @@ class _NotificationsListState extends State<NotificationsList>
return;
}
if (!mounted) return;
_fetchPage(_currentPage);
_fetchPage(_currentPage, animateDiff: true);
UISnackbars.showSuccess(context, 'All notifications marked as read');
}
/// Toggles an item's read state on the backend. Returns true when the item
/// should leave the current list (so the caller can remove it); the actual
/// removal is performed by [_removeItem] via the card's `onRemove` callback,
/// which keeps the animated list and `_items` in sync. Under the "All" filter
/// the item stays and is just recoloured in place.
Future<bool> _setRead(oott_model.Notification item, bool read) async {
if (item.isNew == !read) {
if (mounted) {
@@ -224,18 +261,92 @@ class _NotificationsListState extends State<NotificationsList>
context,
'Event marked as ${read ? 'read' : 'unread'}',
);
if (_filter != _NotificationFilter.all) {
setState(() => _items = _items.where((n) => n.id != item.id).toList());
return true;
}
setState(
() => _items = _items
.map((n) => n.id == item.id ? n.copyWith(isNew: !read) : n)
.toList(),
);
if (_filter != _NotificationFilter.all) return true;
setState(() {
final i = _items.indexWhere((n) => n.id == item.id);
if (i != -1) _items[i] = _items[i].copyWith(isNew: !read);
});
return false;
}
/// Inserts [item] at [index] with a slide-in animation and queues its
/// arrival highlight. Mutates `_items` in lockstep with the animated list.
void _animatedInsert(int index, oott_model.Notification item) {
_items.insert(index, item);
_flashIds.add(item.id);
_listKey.currentState?.insertItem(index, duration: _insertDuration);
}
/// Removes the item with [id] from `_items` and the animated list. When
/// [animated] is true the row collapses with a slide/fade; when false (a
/// swipe, already animated by [Dismissible]) it is dropped instantly.
void _removeItem(int id, {required bool animated}) {
final index = _items.indexWhere((n) => n.id == id);
if (index == -1) return;
final removed = _items.removeAt(index);
_listKey.currentState?.removeItem(
index,
(context, animation) => animated
? _buildRemovingRow(removed, animation)
: const SizedBox.shrink(),
duration: animated ? _removeDuration : Duration.zero,
);
}
/// Removes an item in response to a card action, then refreshes the
/// surrounding chrome (header button, pagination, empty state).
void _removeAndSettle(int id, {required bool animated}) {
_removeItem(id, animated: animated);
_afterStructuralChange();
}
/// Reconciles the live list with a freshly fetched [incoming] page: drops
/// rows the backend no longer returns, applies in-place read-state changes,
/// and slides newly fetched rows in at the top. Cheap O(n) scans suit the
/// small page sizes and read more clearly than a full diff.
void _reconcile(List<oott_model.Notification> incoming) {
final incomingIds = incoming.map((n) => n.id).toSet();
// Removals first, high index to low so earlier indices stay valid.
for (var i = _items.length - 1; i >= 0; i--) {
if (!incomingIds.contains(_items[i].id)) {
_removeItem(_items[i].id, animated: true);
}
}
// In-place read-state changes (e.g. "mark all as read" under "All").
for (final n in incoming) {
final i = _items.indexWhere((x) => x.id == n.id);
if (i != -1 && _items[i].isNew != n.isNew) _items[i] = n;
}
// Insert fresh ids at their position in the newest-first ordering.
final present = _items.map((n) => n.id).toSet();
for (var i = 0; i < incoming.length; i++) {
final n = incoming[i];
if (!present.contains(n.id)) {
_animatedInsert(i.clamp(0, _items.length), n);
present.add(n.id);
}
}
_afterStructuralChange();
}
/// Rebuilds the surrounding widgets after the list's contents change. When
/// the list has emptied, the rebuild is deferred so exit animations finish
/// before the empty state replaces the animated list (which would cut them
/// off); otherwise it runs immediately to refresh the header and pagination.
void _afterStructuralChange() {
if (_items.isEmpty) {
Future.delayed(_removeDuration, () {
if (mounted) setState(() {});
});
} else if (mounted) {
setState(() {});
}
}
String _emptyMessage() => switch (_filter) {
_NotificationFilter.newOnly => 'No new notifications',
_NotificationFilter.oldOnly => 'No old notifications',
@@ -250,7 +361,7 @@ class _NotificationsListState extends State<NotificationsList>
_buildNotificationsHeader(context),
Expanded(
child: RefreshIndicator(
onRefresh: () => _fetchPage(_currentPage),
onRefresh: () => _fetchPage(_currentPage, animateDiff: true),
child: CustomScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
@@ -339,18 +450,53 @@ class _NotificationsListState extends State<NotificationsList>
}
Widget _buildNotificationSliver() {
final formatter = FriendlyDateFormatter();
return SliverList.builder(
itemCount: _items.length,
itemBuilder: (context, index) {
final item = _items[index];
return NotificationCard(
return SliverAnimatedList(
key: _listKey,
initialItemCount: _items.length,
itemBuilder: (context, index, animation) =>
_buildRow(_items[index], animation),
);
}
Widget _buildRow(oott_model.Notification item, Animation<double> animation) {
return SizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: FadeTransition(
opacity: animation,
child: NotificationCard(
key: ValueKey(item.id),
item: item,
formatter: formatter,
formatter: _formatter,
flash: _flashIds.contains(item.id),
onFlashComplete: () => _flashIds.remove(item.id),
onSetRead: (read) => _setRead(item, read),
);
},
onRemove: ({required bool animated}) =>
_removeAndSettle(item.id, animated: animated),
),
),
);
}
// Builds a disappearing row for the animated list's removal transition. It is
// inert (no key, no interaction) so it can't clash with a live card.
Widget _buildRemovingRow(
oott_model.Notification item,
Animation<double> animation,
) {
return SizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: FadeTransition(
opacity: animation,
child: IgnorePointer(
child: NotificationCard(
item: item,
formatter: _formatter,
onSetRead: (_) async => false,
),
),
),
);
}
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/home/notification_card.dart';
import 'package:frontend/home/notifications_list.dart';
import 'package:frontend/theme/gruvbox_theme.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
@@ -149,4 +150,219 @@ void main() {
await tester.pumpWidget(const SizedBox());
});
testWidgets('swiping a "New" notification marks it read and removes it', (
tester,
) 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},
);
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.byType(NotificationCard), findsNWidgets(2));
// Swipe the first card right-to-left (endToStart) to mark it read; it then
// animates out and the second card takes its place. Pump in steps rather
// than pumpAndSettle: confirmDismiss awaits the (mocked) backend call, and
// pumpAndSettle would treat that async gap as settled and return early.
await tester.fling(
find.byType(Dismissible).first,
const Offset(-600, 0),
1000,
);
for (
var i = 0;
i < 40 && find.textContaining('First').evaluate().isNotEmpty;
i++
) {
await tester.pump(const Duration(milliseconds: 16));
}
expect(find.textContaining('First'), findsNothing);
expect(find.textContaining('Second'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
});
testWidgets('a newly fetched notification flashes in at the top', (
tester,
) async {
tester.view.physicalSize = const Size(400, 600);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
final items = <Map<String, dynamic>>[
notificationJson(id: 1, title: 'Existing'),
];
adapter.onGet(
'/notifications',
(server) => server.reply(200, items),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
);
await tester.pumpWidget(
MaterialApp(
theme: gruvboxDarkTheme,
home: const Scaffold(body: NotificationsList()),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Existing'), findsOneWidget);
// A new notification arrives at the top on the next pull-to-refresh.
items.insert(0, notificationJson(id: 2, title: 'Arrived'));
await tester.fling(
find.byType(CustomScrollView),
const Offset(0, 300),
1000,
);
// Pump in small steps until the new card has been inserted and is flashing
// (the refresh indicator takes a moment to fire before the insert begins).
var flashing = const Iterable<NotificationCard>.empty();
for (var i = 0; i < 60 && flashing.isEmpty; i++) {
await tester.pump(const Duration(milliseconds: 16));
flashing = tester
.widgetList<NotificationCard>(find.byType(NotificationCard))
.where((c) => c.flash);
}
// Only the freshly arrived card runs its arrival highlight.
expect(flashing.length, 1);
expect(flashing.single.item.title, 'Arrived');
await tester.pumpAndSettle();
expect(find.textContaining('Arrived'), findsOneWidget);
expect(find.textContaining('Existing'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
});
testWidgets('a notification dropped by the backend is removed on refresh', (
tester,
) async {
tester.view.physicalSize = const Size(400, 600);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
final items = <Map<String, dynamic>>[
notificationJson(id: 1, title: 'Stays'),
notificationJson(id: 2, title: 'Vanishes'),
];
adapter.onGet(
'/notifications',
(server) => server.reply(200, items),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 5},
);
await tester.pumpWidget(
MaterialApp(
theme: gruvboxDarkTheme,
home: const Scaffold(body: NotificationsList()),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Vanishes'), findsOneWidget);
// The second notification is gone from the backend on the next refresh.
items.removeWhere((j) => j['id'] == 2);
await tester.fling(
find.byType(CustomScrollView),
const Offset(0, 300),
1000,
);
await tester.pumpAndSettle();
expect(find.textContaining('Vanishes'), findsNothing);
expect(find.textContaining('Stays'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
});
testWidgets('marking read under the "All" filter keeps the item in place', (
tester,
) async {
adapter.onGet(
'/notifications',
(server) => server.reply(200, [notificationJson(id: 1, title: 'Kept')]),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 6},
);
adapter.onGet(
'/notifications',
(server) => server.reply(200, [notificationJson(id: 1, title: 'Kept')]),
queryParameters: {'is_new': '', '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();
// Switch to the All filter, where read items stay in the list.
await tester.tap(find.text('All'));
await tester.pumpAndSettle();
expect(find.textContaining('Kept'), findsOneWidget);
// Expand the card and mark it read in place.
await tester.tap(find.byType(ListTile));
await tester.pumpAndSettle();
await tester.tap(find.text('Mark as read'));
await tester.pumpAndSettle();
// The item stays, now flipped to "old" (offering to mark it unread).
expect(find.textContaining('Kept'), findsOneWidget);
expect(find.text('Mark as unread'), findsOneWidget);
await tester.pumpWidget(const SizedBox());
});
testWidgets('changing the filter resets the list to the new dataset', (
tester,
) async {
adapter.onGet(
'/notifications',
(server) =>
server.reply(200, [notificationJson(id: 1, title: 'New one')]),
queryParameters: {'is_new': true, 'page_offset': 0, 'page_limit': 6},
);
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},
);
await tester.pumpWidget(
MaterialApp(
theme: gruvboxDarkTheme,
home: const Scaffold(body: NotificationsList()),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('New one'), findsOneWidget);
await tester.tap(find.text('Old'));
await tester.pumpAndSettle();
expect(find.textContaining('Old one'), findsOneWidget);
expect(find.textContaining('New one'), findsNothing);
await tester.pumpWidget(const SizedBox());
});
}