mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
468 lines
15 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|