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 trailingSlivers; @override State createState() => _NotificationsListState(); } class _NotificationsListState extends State with RouteAware, WidgetsBindingObserver, PaginatedListState { _NotificationFilter _filter = _NotificationFilter.newOnly; Timer? _notificationTimer; List _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 _listKey = GlobalKey(); // Ids of freshly arrived items that should play their highlight on next build. final Set _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) { 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 _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 _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 _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 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 _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 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 animation, ) { return SizeTransition( sizeFactor: animation, axisAlignment: -1, child: FadeTransition( opacity: animation, child: IgnorePointer( child: NotificationCard( item: item, formatter: _formatter, onSetRead: (_) async => false, ), ), ), ); } }