Files
oott/frontend/lib/home/notifications_list.dart
T
2026-06-07 15:37:30 -04:00

468 lines
15 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import '../model/notification.dart' as oott_model;
import '../navigation.dart';
import '../utils/friendly_date_formatter.dart';
import '../utils/oott_api.dart';
import '../utils/paginated_list_state.dart';
import '../utils/ui_snackbars.dart';
import '../widgets/empty_state.dart';
import '../widgets/filter_selector.dart';
import '../widgets/pagination_bar.dart';
import '../widgets/skeleton.dart';
import 'notification_card.dart';
// Durations for the list's enter/exit animations.
const _insertDuration = Duration(milliseconds: 300);
const _removeDuration = Duration(milliseconds: 250);
enum _NotificationFilter {
newOnly('New'),
oldOnly('Old'),
all('All');
const _NotificationFilter(this.label);
final String label;
bool? get isNew => switch (this) {
_NotificationFilter.newOnly => true,
_NotificationFilter.oldOnly => false,
_NotificationFilter.all => null,
};
}
class NotificationsList extends StatefulWidget {
const NotificationsList({super.key, this.trailingSlivers = const []});
final List<Widget> trailingSlivers;
@override
State<NotificationsList> createState() => _NotificationsListState();
}
class _NotificationsListState extends State<NotificationsList>
with
RouteAware,
WidgetsBindingObserver,
PaginatedListState<NotificationsList> {
_NotificationFilter _filter = _NotificationFilter.newOnly;
Timer? _notificationTimer;
List<oott_model.Notification> _items = [];
// 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();
// Phones show fewer notifications so the list and its pagination controls fit
// on screen at once on the common current phones (e.g. iPhone 15, Pixel 8);
// wider layouts have the vertical room for a couple more.
@override
int get phonePageSize => 4;
@override
int get widePageSize => 5;
@override
bool get isListEmpty => _items.isEmpty;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_startTimer();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (route is ModalRoute<void>) {
routeObserver.subscribe(this, route);
}
// Deferred from initState so the page size can read the screen width from
// MediaQuery, which is only available once dependencies are in place.
if (!didInitialFetch) {
didInitialFetch = true;
_fetchPage(0);
}
}
@override
void didPushNext() {
_notificationTimer?.cancel();
_notificationTimer = null;
}
@override
void didPopNext() {
_fetchPage(currentPage, animateDiff: true);
_startTimer();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
_notificationTimer?.cancel();
_notificationTimer = null;
} else if (state == AppLifecycleState.resumed) {
_fetchPage(currentPage, animateDiff: true);
_startTimer();
}
}
void _startTimer() {
_notificationTimer?.cancel();
_notificationTimer = Timer.periodic(
const Duration(minutes: 1),
(_) => _fetchPage(currentPage, animateDiff: true),
);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
routeObserver.unsubscribe(this);
_notificationTimer?.cancel();
super.dispose();
}
/// Fetches [page]. When [scrollToTop] is set, the list animates back to its
/// first item, so changing pages always starts the new page from the top.
/// 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,
}) {
return runFetch(
page,
scrollToTop: scrollToTop,
paging: paging,
fetch: (page, perPage, token) => BackendAPI.instance.listNotifications(
_filter.isNew,
page: page,
perPage: perPage,
cancelToken: token,
),
onResult: (result) {
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;
totalCount = result.totalCount;
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;
totalCount = result.totalCount;
_items = result.items;
isLoading = false;
isPaging = false;
});
},
);
}
Future<void> _markAllAsRead() async {
try {
await BackendAPI.instance.markAllNotificationsAsRead();
} catch (e) {
if (!mounted) return;
UISnackbars.showError(context, dioErrorToUserMessage(e));
return;
}
if (!mounted) return;
_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) {
UISnackbars.showWarning(
context,
'Notification was already marked as ${read ? 'read' : 'unread'}',
);
}
return false;
}
try {
if (read) {
await BackendAPI.instance.markNotificationAsRead(item.id);
} else {
await BackendAPI.instance.markNotificationAsNew(item.id);
}
} catch (e) {
if (!mounted) return false;
UISnackbars.showError(context, dioErrorToUserMessage(e));
return false;
}
if (!mounted) return false;
UISnackbars.showSuccess(
context,
'Event marked as ${read ? 'read' : 'unread'}',
);
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 (swipe or mark read/unread
/// that drops it from the current filter), then refreshes the surrounding
/// chrome (header button, pagination, empty state). The total is decremented
/// locally so the page count stays accurate without re-fetching; background
/// refreshes re-sync it from the backend. Removals driven by [_reconcile] use
/// [_removeItem] directly so they don't double-count against a fresh total.
void _removeAndSettle(int id, {required bool animated}) {
_removeItem(id, animated: animated);
if (totalCount > 0) totalCount--;
_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 news, good news?',
_NotificationFilter.oldOnly => 'No old notifications',
_NotificationFilter.all => 'No notifications yet',
};
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildNotificationsHeader(context),
Expanded(
child: RefreshIndicator(
onRefresh: () => _fetchPage(currentPage, animateDiff: true),
child: CustomScrollView(
controller: scrollController,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
..._buildNotificationSlivers(context),
...widget.trailingSlivers,
],
),
),
),
],
);
}
Widget _buildNotificationsHeader(BuildContext context) {
return Row(
children: [
Expanded(
child: FilterSelector<_NotificationFilter>(
values: _NotificationFilter.values,
selected: _filter,
labelOf: (f) => f.label,
onSelected: (f) {
setState(() => _filter = f);
_fetchPage(0);
},
),
),
if (_filter == _NotificationFilter.newOnly && _items.isNotEmpty)
IconButton(
onPressed: _markAllAsRead,
icon: const Icon(Icons.done_all),
tooltip: 'Mark all as read',
),
],
);
}
List<Widget> _buildNotificationSlivers(BuildContext context) {
if (isLoading) {
return [const SliverToBoxAdapter(child: ListSkeleton(rows: 4))];
}
if (error != null) {
return [
SliverFillRemaining(
child: Center(
child: Text(
'Error: $error',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
),
];
}
if (_items.isEmpty) {
return [
SliverToBoxAdapter(
child: EmptyState(
icon: Icons.notifications_off_outlined,
message: _emptyMessage(),
),
),
];
}
return [
_buildNotificationSliver(),
if (currentPage > 0 || totalPages > 1)
SliverToBoxAdapter(
child: PaginationBar(
currentPage: currentPage,
totalPages: totalPages,
isLoading: isPaging,
onPageChanged: (page) =>
_fetchPage(page, scrollToTop: true, paging: true),
),
),
];
}
Widget _buildNotificationSliver() {
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,
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,
),
),
),
);
}
}