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
+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,
),
),
),
);
}
}