diff --git a/lib/account/bloc/in_app_notification_center_bloc.dart b/lib/account/bloc/in_app_notification_center_bloc.dart index d840620e..6d54b468 100644 --- a/lib/account/bloc/in_app_notification_center_bloc.dart +++ b/lib/account/bloc/in_app_notification_center_bloc.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:collection/collection.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; @@ -30,85 +31,135 @@ class InAppNotificationCenterBloc _appBloc = appBloc, _logger = logger, super(const InAppNotificationCenterState()) { - on(_onSubscriptionRequested); + on( + _onSubscriptionRequested, + transformer: droppable(), + ); on(_onMarkedAsRead); on(_onMarkAllAsRead); on(_onTabChanged); on(_onMarkOneAsRead); + on( + _onFetchMoreRequested, + transformer: droppable(), + ); } + /// The number of notifications to fetch per page. + static const _notificationsFetchLimit = 10; + final DataRepository _inAppNotificationRepository; final AppBloc _appBloc; final Logger _logger; - /// Handles the request to load all notifications for the current user. + /// Handles the initial subscription request to fetch notifications for both + /// tabs concurrently. Future _onSubscriptionRequested( InAppNotificationCenterSubscriptionRequested event, Emitter emit, ) async { emit(state.copyWith(status: InAppNotificationCenterStatus.loading)); - final userId = _appBloc.state.user?.id; if (userId == null) { - _logger.warning('Cannot fetch notifications: user is not logged in.'); + _logger.warning( + 'Cannot fetch more notifications: user is not logged in.', + ); emit(state.copyWith(status: InAppNotificationCenterStatus.failure)); return; } try { - final response = await _inAppNotificationRepository.readAll( - userId: userId, - sort: [const SortOption('createdAt', SortOrder.desc)], - ); + // Fetch both tabs' initial data in parallel and wait for their results. + final results = await Future.wait([ + _fetchNotifications(userId: userId, filter: _breakingNewsFilter), + _fetchNotifications(userId: userId, filter: _digestFilter), + ]); - final allNotifications = response.items; - - final breakingNews = []; - final digests = []; - - // Filter notifications into their respective categories, prioritizing - // 'notificationType' from the backend, then falling back to 'contentType'. - for (final n in allNotifications) { - final notificationType = n.payload.data['notificationType'] as String?; - final contentType = n.payload.data['contentType'] as String?; - - if (notificationType == - PushNotificationSubscriptionDeliveryType.dailyDigest.name || - notificationType == - PushNotificationSubscriptionDeliveryType.weeklyRoundup.name || - contentType == 'digest') { - digests.add(n); - } else { - // All other types (including 'breakingOnly' notificationType, - // 'headline' contentType, or any unknown types) go to breaking news. - breakingNews.add(n); - } - } + final breakingNewsResponse = results[0]; + final digestResponse = results[1]; + // Perform a single, atomic state update with both results. emit( state.copyWith( status: InAppNotificationCenterStatus.success, - breakingNewsNotifications: breakingNews, - digestNotifications: digests, + breakingNewsNotifications: breakingNewsResponse.items, + breakingNewsHasMore: breakingNewsResponse.hasMore, + breakingNewsCursor: breakingNewsResponse.cursor, + digestNotifications: digestResponse.items, + digestHasMore: digestResponse.hasMore, + digestCursor: digestResponse.cursor, ), ); - } on HttpException catch (e, s) { - _logger.severe('Failed to fetch in-app notifications.', e, s); - emit( - state.copyWith(status: InAppNotificationCenterStatus.failure, error: e), - ); - } catch (e, s) { - _logger.severe( - 'An unexpected error occurred while fetching in-app notifications.', - e, - s, + } catch (error, stackTrace) { + _handleFetchError(emit, error, stackTrace); + } + } + + /// Handles fetching the next page of notifications for the current tab. + Future _onFetchMoreRequested( + InAppNotificationCenterFetchMoreRequested event, + Emitter emit, + ) async { + final isBreakingNewsTab = state.currentTabIndex == 0; + final hasMore = isBreakingNewsTab + ? state.breakingNewsHasMore + : state.digestHasMore; + + if (state.status == InAppNotificationCenterStatus.loadingMore || !hasMore) { + return; + } + + emit(state.copyWith(status: InAppNotificationCenterStatus.loadingMore)); + + final userId = _appBloc.state.user?.id; + if (userId == null) { + _logger.warning( + 'Cannot fetch more notifications: user is not logged in.', ); - emit( - state.copyWith( - status: InAppNotificationCenterStatus.failure, - error: UnknownException(e.toString()), - ), + emit(state.copyWith(status: InAppNotificationCenterStatus.failure)); + return; + } + + final filter = isBreakingNewsTab ? _breakingNewsFilter : _digestFilter; + final cursor = isBreakingNewsTab + ? state.breakingNewsCursor + : state.digestCursor; + + try { + final response = await _fetchNotifications( + userId: userId, + filter: filter, + cursor: cursor, ); + + // Append the new items to the correct list. + if (isBreakingNewsTab) { + emit( + state.copyWith( + status: InAppNotificationCenterStatus.success, + breakingNewsNotifications: [ + ...state.breakingNewsNotifications, + ...response.items, + ], + breakingNewsHasMore: response.hasMore, + breakingNewsCursor: response.cursor, + ), + ); + } else { + emit( + state.copyWith( + status: InAppNotificationCenterStatus.success, + digestNotifications: [ + ...state.digestNotifications, + ...response.items, + ], + digestHasMore: response.hasMore, + digestCursor: response.cursor, + ), + ); + } + } catch (error, stackTrace) { + _handleFetchError(emit, error, stackTrace); } } @@ -117,6 +168,8 @@ class InAppNotificationCenterBloc InAppNotificationCenterTabChanged event, Emitter emit, ) async { + // If the tab is changed, we don't need to re-fetch data as it was + // already fetched on initial load. We just update the index. emit(state.copyWith(currentTabIndex: event.tabIndex)); } @@ -280,4 +333,68 @@ class InAppNotificationCenterBloc ); } } + + /// A generic method to fetch notifications based on a filter. + Future> _fetchNotifications({ + required String userId, + required Map filter, + String? cursor, + }) async { + // This method now simply fetches and returns the data, or throws on error. + // The responsibility of emitting state is moved to the event handlers. + return _inAppNotificationRepository.readAll( + userId: userId, + filter: filter, + pagination: PaginationOptions( + limit: _notificationsFetchLimit, + cursor: cursor, + ), + sort: [const SortOption('createdAt', SortOrder.desc)], + ); + } + + /// Filter for "Breaking News" notifications. + /// + /// This filter uses the `$nin` (not in) operator to exclude notifications + /// that are explicitly typed as digests. All other notifications are + /// considered "breaking news" for the purpose of this tab. + Map get _breakingNewsFilter => { + 'payload.data.notificationType': { + r'$nin': [ + PushNotificationSubscriptionDeliveryType.dailyDigest.name, + PushNotificationSubscriptionDeliveryType.weeklyRoundup.name, + ], + }, + }; + + /// Filter for "Digests" notifications. + /// + /// This filter uses the `$in` operator to select notifications that are + /// explicitly typed as either a daily or weekly digest. + Map get _digestFilter => { + 'payload.data.notificationType': { + r'$in': [ + PushNotificationSubscriptionDeliveryType.dailyDigest.name, + PushNotificationSubscriptionDeliveryType.weeklyRoundup.name, + ], + }, + }; + + /// Centralized error handler for fetch operations. + void _handleFetchError( + Emitter emit, + Object error, + StackTrace stackTrace, + ) { + _logger.severe('Failed to fetch notifications.', error, stackTrace); + final httpException = error is HttpException + ? error + : UnknownException(error.toString()); + emit( + state.copyWith( + status: InAppNotificationCenterStatus.failure, + error: httpException, + ), + ); + } } diff --git a/lib/account/bloc/in_app_notification_center_event.dart b/lib/account/bloc/in_app_notification_center_event.dart index f44ca1e7..e791a0d3 100644 --- a/lib/account/bloc/in_app_notification_center_event.dart +++ b/lib/account/bloc/in_app_notification_center_event.dart @@ -55,3 +55,10 @@ class InAppNotificationCenterMarkOneAsRead @override List get props => [notificationId]; } + +/// Dispatched when the user scrolls to the end of a notification list and +/// more data needs to be fetched. +class InAppNotificationCenterFetchMoreRequested + extends InAppNotificationCenterEvent { + const InAppNotificationCenterFetchMoreRequested(); +} diff --git a/lib/account/bloc/in_app_notification_center_state.dart b/lib/account/bloc/in_app_notification_center_state.dart index ded68a2e..2aba115a 100644 --- a/lib/account/bloc/in_app_notification_center_state.dart +++ b/lib/account/bloc/in_app_notification_center_state.dart @@ -8,6 +8,9 @@ enum InAppNotificationCenterStatus { /// The state when notifications are being loaded. loading, + /// The state when more notifications are being loaded for pagination. + loadingMore, + /// The state when notifications have been successfully loaded. success, @@ -25,6 +28,10 @@ class InAppNotificationCenterState extends Equatable { this.breakingNewsNotifications = const [], this.digestNotifications = const [], this.currentTabIndex = 0, + this.breakingNewsHasMore = true, + this.breakingNewsCursor, + this.digestHasMore = true, + this.digestCursor, this.error, }); @@ -50,12 +57,28 @@ class InAppNotificationCenterState extends Equatable { /// An error that occurred during notification loading or processing. final HttpException? error; + /// A flag indicating if there are more breaking news notifications to fetch. + final bool breakingNewsHasMore; + + /// The cursor for fetching the next page of breaking news notifications. + final String? breakingNewsCursor; + + /// A flag indicating if there are more digest notifications to fetch. + final bool digestHasMore; + + /// The cursor for fetching the next page of digest notifications. + final String? digestCursor; + @override List get props => [ status, currentTabIndex, breakingNewsNotifications, digestNotifications, + breakingNewsHasMore, + breakingNewsCursor ?? Object(), + digestHasMore, + digestCursor ?? Object(), error ?? Object(), // Include error in props, handle nullability ]; @@ -67,14 +90,29 @@ class InAppNotificationCenterState extends Equatable { int? currentTabIndex, List? breakingNewsNotifications, List? digestNotifications, + bool? breakingNewsHasMore, + // Use a nullable wrapper to explicitly set the cursor to null. + Object? breakingNewsCursor, + bool? digestHasMore, + Object? digestCursor, }) { return InAppNotificationCenterState( status: status ?? this.status, - error: error ?? this.error, + // Allow explicitly setting the error to null. + // ignore: avoid_redundant_argument_values + error: error, currentTabIndex: currentTabIndex ?? this.currentTabIndex, breakingNewsNotifications: breakingNewsNotifications ?? this.breakingNewsNotifications, digestNotifications: digestNotifications ?? this.digestNotifications, + breakingNewsHasMore: breakingNewsHasMore ?? this.breakingNewsHasMore, + breakingNewsCursor: breakingNewsCursor == null + ? this.breakingNewsCursor + : breakingNewsCursor as String?, + digestHasMore: digestHasMore ?? this.digestHasMore, + digestCursor: digestCursor == null + ? this.digestCursor + : digestCursor as String?, ); } } diff --git a/lib/account/view/in_app_notification_center_page.dart b/lib/account/view/in_app_notification_center_page.dart index e8d8af8a..c3bf325d 100644 --- a/lib/account/view/in_app_notification_center_page.dart +++ b/lib/account/view/in_app_notification_center_page.dart @@ -102,7 +102,9 @@ class _InAppNotificationCenterPageState } }, builder: (context, state) { - if (state.status == InAppNotificationCenterStatus.loading) { + if (state.status == InAppNotificationCenterStatus.loading && + state.breakingNewsNotifications.isEmpty && + state.digestNotifications.isEmpty) { return LoadingStateWidget( icon: Icons.notifications_none_outlined, headline: l10n.notificationCenterLoadingHeadline, @@ -110,7 +112,9 @@ class _InAppNotificationCenterPageState ); } - if (state.status == InAppNotificationCenterStatus.failure) { + if (state.status == InAppNotificationCenterStatus.failure && + state.breakingNewsNotifications.isEmpty && + state.digestNotifications.isEmpty) { return FailureStateWidget( exception: state.error ?? @@ -129,9 +133,15 @@ class _InAppNotificationCenterPageState controller: _tabController, children: [ _NotificationList( + status: state.status, notifications: state.breakingNewsNotifications, + hasMore: state.breakingNewsHasMore, + ), + _NotificationList( + status: state.status, + notifications: state.digestNotifications, + hasMore: state.digestHasMore, ), - _NotificationList(notifications: state.digestNotifications), ], ); }, @@ -140,16 +150,61 @@ class _InAppNotificationCenterPageState } } -class _NotificationList extends StatelessWidget { - const _NotificationList({required this.notifications}); +class _NotificationList extends StatefulWidget { + const _NotificationList({ + required this.notifications, + required this.hasMore, + required this.status, + }); + final InAppNotificationCenterStatus status; final List notifications; + final bool hasMore; + + @override + State<_NotificationList> createState() => _NotificationListState(); +} + +class _NotificationListState extends State<_NotificationList> { + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController + ..removeListener(_onScroll) + ..dispose(); + super.dispose(); + } + + void _onScroll() { + final bloc = context.read(); + if (_isBottom && + widget.hasMore && + bloc.state.status != InAppNotificationCenterStatus.loadingMore) { + bloc.add(const InAppNotificationCenterFetchMoreRequested()); + } + } + + bool get _isBottom { + if (!_scrollController.hasClients) return false; + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.offset; + return currentScroll >= (maxScroll * 0.98); + } @override Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; - if (notifications.isEmpty) { + // Show empty state only if not in the middle of an initial load. + if (widget.notifications.isEmpty && + widget.status != InAppNotificationCenterStatus.loading) { return InitialStateWidget( icon: Icons.notifications_off_outlined, headline: l10n.notificationCenterEmptyHeadline, @@ -158,10 +213,21 @@ class _NotificationList extends StatelessWidget { } return ListView.separated( - itemCount: notifications.length, + controller: _scrollController, + itemCount: widget.hasMore + ? widget.notifications.length + 1 + : widget.notifications.length, separatorBuilder: (context, index) => const Divider(height: 1), itemBuilder: (context, index) { - final notification = notifications[index]; + if (index >= widget.notifications.length) { + return widget.status == InAppNotificationCenterStatus.loadingMore + ? const Padding( + padding: EdgeInsets.symmetric(vertical: AppSpacing.lg), + child: Center(child: CircularProgressIndicator()), + ) + : const SizedBox.shrink(); + } + final notification = widget.notifications[index]; return InAppNotificationListItem( notification: notification, onTap: () async { diff --git a/pubspec.lock b/pubspec.lock index 34844ee3..2a501857 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -185,8 +185,8 @@ packages: dependency: "direct main" description: path: "." - ref: b01f9b457d4faaa6fab58f67ebc09d93314e6b10 - resolved-ref: b01f9b457d4faaa6fab58f67ebc09d93314e6b10 + ref: "8a03af9af8ccf4920388ddce73062aca2fde1bc9" + resolved-ref: "8a03af9af8ccf4920388ddce73062aca2fde1bc9" url: "https://github.com/flutter-news-app-full-source-code/core.git" source: git version: "1.3.1" diff --git a/pubspec.yaml b/pubspec.yaml index a00c8646..5c701f37 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -145,7 +145,7 @@ dependency_overrides: core: git: url: https://github.com/flutter-news-app-full-source-code/core.git - ref: b01f9b457d4faaa6fab58f67ebc09d93314e6b10 + ref: 8a03af9af8ccf4920388ddce73062aca2fde1bc9 http_client: git: url: https://github.com/flutter-news-app-full-source-code/http-client.git