mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
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:
co-authored by
Claude Opus 4.8
parent
c949deedb6
commit
f847b60c21
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user