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
@@ -28,7 +28,7 @@
|
|||||||
- [x] Mobile - reduce lists length (# of items) so they fit on a phone in one screen (use iPhone latest gen and Google phone latest gen)
|
- [x] Mobile - reduce lists length (# of items) so they fit on a phone in one screen (use iPhone latest gen and Google phone latest gen)
|
||||||
- [x] Mobile - In the devices list the filters and sort buttons overlap (dont fit in the screen)
|
- [x] Mobile - In the devices list the filters and sort buttons overlap (dont fit in the screen)
|
||||||
- [x] When changing pages (either list) the items should change to placeholders while loading
|
- [x] When changing pages (either list) the items should change to placeholders while loading
|
||||||
- [ ] The notifications list should not refresh coldly every time. It should add/remove notifications with an animation as if a stack
|
- [x] The notifications list should not refresh coldly every time. It should add/remove notifications with an animation as if a stack
|
||||||
- [x] Make gruvbox the default theme
|
- [x] Make gruvbox the default theme
|
||||||
- [x] Can we add front-end tests?
|
- [x] Can we add front-end tests?
|
||||||
- [x] Break down oott_api.dart in modules
|
- [x] Break down oott_api.dart in modules
|
||||||
|
|||||||
@@ -2,18 +2,38 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../model/notification.dart' as oott_model;
|
import '../model/notification.dart' as oott_model;
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
import '../theme/dimens.dart';
|
import '../theme/dimens.dart';
|
||||||
import '../utils/friendly_date_formatter.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 {
|
class NotificationCard extends StatefulWidget {
|
||||||
final oott_model.Notification item;
|
final oott_model.Notification item;
|
||||||
final FriendlyDateFormatter formatter;
|
final FriendlyDateFormatter formatter;
|
||||||
final Future<bool> Function(bool read) onSetRead;
|
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({
|
const NotificationCard({
|
||||||
required this.item,
|
required this.item,
|
||||||
required this.formatter,
|
required this.formatter,
|
||||||
required this.onSetRead,
|
required this.onSetRead,
|
||||||
|
this.flash = false,
|
||||||
|
this.onFlashComplete,
|
||||||
|
this.onRemove,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,7 +51,10 @@ class _NotificationCardState extends State<NotificationCard> {
|
|||||||
alignment: MainAxisAlignment.end,
|
alignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
TextButton(
|
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'),
|
child: Text(widget.item.isNew ? 'Mark as read' : 'Mark as unread'),
|
||||||
),
|
),
|
||||||
if (widget.item.macAddress != null)
|
if (widget.item.macAddress != null)
|
||||||
@@ -39,7 +62,10 @@ class _NotificationCardState extends State<NotificationCard> {
|
|||||||
icon: const Icon(Icons.open_in_new),
|
icon: const Icon(Icons.open_in_new),
|
||||||
label: const Text('View device'),
|
label: const Text('View device'),
|
||||||
onPressed: () async {
|
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) {
|
if (context.mounted) {
|
||||||
context.push('/devices/${widget.item.macAddress}');
|
context.push('/devices/${widget.item.macAddress}');
|
||||||
}
|
}
|
||||||
@@ -55,10 +81,45 @@ class _NotificationCardState extends State<NotificationCard> {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Card(
|
return Card(
|
||||||
color: widget.item.isNew ? theme.colorScheme.secondaryContainer : null,
|
color: widget.item.isNew ? theme.colorScheme.secondaryContainer : null,
|
||||||
child: Dismissible(
|
// 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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDismissible(BuildContext context, ThemeData theme) {
|
||||||
|
return Dismissible(
|
||||||
key: ValueKey(widget.item.id),
|
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) =>
|
confirmDismiss: (direction) =>
|
||||||
widget.onSetRead(direction != DismissDirection.startToEnd),
|
widget.onSetRead(direction != DismissDirection.startToEnd),
|
||||||
|
// The swipe-out animation is already done; the owner just reconciles.
|
||||||
|
onDismissed: (_) => widget.onRemove?.call(animated: false),
|
||||||
background: Container(
|
background: Container(
|
||||||
color: theme.colorScheme.tertiaryContainer,
|
color: theme.colorScheme.tertiaryContainer,
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
@@ -105,7 +166,6 @@ class _NotificationCardState extends State<NotificationCard> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import 'notification_card.dart';
|
|||||||
const _phonePageSize = 4;
|
const _phonePageSize = 4;
|
||||||
const _widePageSize = 5;
|
const _widePageSize = 5;
|
||||||
|
|
||||||
|
// Durations for the list's enter/exit animations.
|
||||||
|
const _insertDuration = Duration(milliseconds: 300);
|
||||||
|
const _removeDuration = Duration(milliseconds: 250);
|
||||||
|
|
||||||
enum _NotificationFilter {
|
enum _NotificationFilter {
|
||||||
newOnly('New'),
|
newOnly('New'),
|
||||||
oldOnly('Old'),
|
oldOnly('Old'),
|
||||||
@@ -58,6 +62,14 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isPaging = 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
|
int get _pageSize => MediaQuery.sizeOf(context).width < Breakpoints.medium
|
||||||
? _phonePageSize
|
? _phonePageSize
|
||||||
: _widePageSize;
|
: _widePageSize;
|
||||||
@@ -96,7 +108,7 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void didPopNext() {
|
void didPopNext() {
|
||||||
_fetchPage(_currentPage);
|
_fetchPage(_currentPage, animateDiff: true);
|
||||||
_startTimer();
|
_startTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +118,7 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
_notificationTimer?.cancel();
|
_notificationTimer?.cancel();
|
||||||
_notificationTimer = null;
|
_notificationTimer = null;
|
||||||
} else if (state == AppLifecycleState.resumed) {
|
} else if (state == AppLifecycleState.resumed) {
|
||||||
_fetchPage(_currentPage);
|
_fetchPage(_currentPage, animateDiff: true);
|
||||||
_startTimer();
|
_startTimer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,7 +127,7 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
_notificationTimer?.cancel();
|
_notificationTimer?.cancel();
|
||||||
_notificationTimer = Timer.periodic(
|
_notificationTimer = Timer.periodic(
|
||||||
const Duration(minutes: 1),
|
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
|
/// When [paging] is set the current page stays visible and the pagination
|
||||||
/// bar shows a progress cue; background refreshes leave [paging] false so
|
/// bar shows a progress cue; background refreshes leave [paging] false so
|
||||||
/// they don't flash the bar.
|
/// 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(
|
Future<void> _fetchPage(
|
||||||
int page, {
|
int page, {
|
||||||
bool scrollToTop = false,
|
bool scrollToTop = false,
|
||||||
bool paging = false,
|
bool paging = false,
|
||||||
|
bool animateDiff = false,
|
||||||
}) async {
|
}) async {
|
||||||
if (scrollToTop && _scrollController.hasClients) {
|
if (scrollToTop && _scrollController.hasClients) {
|
||||||
_scrollController.animateTo(
|
_scrollController.animateTo(
|
||||||
@@ -165,14 +183,28 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
cancelToken: token,
|
cancelToken: token,
|
||||||
);
|
);
|
||||||
if (!mounted || token != _fetchToken) return;
|
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(() {
|
setState(() {
|
||||||
|
_listKey = GlobalKey();
|
||||||
_currentPage = page;
|
_currentPage = page;
|
||||||
_hasNextPage = result.hasNextPage;
|
_hasNextPage = result.hasNextPage;
|
||||||
_items = result.items;
|
_items = result.items;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_isPaging = false;
|
_isPaging = false;
|
||||||
});
|
});
|
||||||
paginationLoading.value = false;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted || token != _fetchToken) return;
|
if (!mounted || token != _fetchToken) return;
|
||||||
if (e is DioException && e.type == DioExceptionType.cancel) return;
|
if (e is DioException && e.type == DioExceptionType.cancel) return;
|
||||||
@@ -194,10 +226,15 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_fetchPage(_currentPage);
|
_fetchPage(_currentPage, animateDiff: true);
|
||||||
UISnackbars.showSuccess(context, 'All notifications marked as read');
|
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 {
|
Future<bool> _setRead(oott_model.Notification item, bool read) async {
|
||||||
if (item.isNew == !read) {
|
if (item.isNew == !read) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -224,18 +261,92 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
context,
|
context,
|
||||||
'Event marked as ${read ? 'read' : 'unread'}',
|
'Event marked as ${read ? 'read' : 'unread'}',
|
||||||
);
|
);
|
||||||
if (_filter != _NotificationFilter.all) {
|
if (_filter != _NotificationFilter.all) return true;
|
||||||
setState(() => _items = _items.where((n) => n.id != item.id).toList());
|
setState(() {
|
||||||
return true;
|
final i = _items.indexWhere((n) => n.id == item.id);
|
||||||
}
|
if (i != -1) _items[i] = _items[i].copyWith(isNew: !read);
|
||||||
setState(
|
});
|
||||||
() => _items = _items
|
|
||||||
.map((n) => n.id == item.id ? n.copyWith(isNew: !read) : n)
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
return false;
|
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) {
|
String _emptyMessage() => switch (_filter) {
|
||||||
_NotificationFilter.newOnly => 'No new notifications',
|
_NotificationFilter.newOnly => 'No new notifications',
|
||||||
_NotificationFilter.oldOnly => 'No old notifications',
|
_NotificationFilter.oldOnly => 'No old notifications',
|
||||||
@@ -250,7 +361,7 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
_buildNotificationsHeader(context),
|
_buildNotificationsHeader(context),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: () => _fetchPage(_currentPage),
|
onRefresh: () => _fetchPage(_currentPage, animateDiff: true),
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
@@ -339,18 +450,53 @@ class _NotificationsListState extends State<NotificationsList>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNotificationSliver() {
|
Widget _buildNotificationSliver() {
|
||||||
final formatter = FriendlyDateFormatter();
|
return SliverAnimatedList(
|
||||||
return SliverList.builder(
|
key: _listKey,
|
||||||
itemCount: _items.length,
|
initialItemCount: _items.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index, animation) =>
|
||||||
final item = _items[index];
|
_buildRow(_items[index], animation),
|
||||||
return NotificationCard(
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
key: ValueKey(item.id),
|
||||||
item: item,
|
item: item,
|
||||||
formatter: formatter,
|
formatter: _formatter,
|
||||||
|
flash: _flashIds.contains(item.id),
|
||||||
|
onFlashComplete: () => _flashIds.remove(item.id),
|
||||||
onSetRead: (read) => _setRead(item, read),
|
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/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/home/notifications_list.dart';
|
||||||
import 'package:frontend/theme/gruvbox_theme.dart';
|
import 'package:frontend/theme/gruvbox_theme.dart';
|
||||||
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
||||||
@@ -149,4 +150,219 @@ void main() {
|
|||||||
|
|
||||||
await tester.pumpWidget(const SizedBox());
|
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());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user