diff --git a/README.md b/README.md index 677850e1..9d7e8c0d 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ A robust, backend-driven notification system keeps users informed and brings the - **Multi-Provider Architecture:** Built on an abstraction that supports any push notification service. It ships with production-ready providers for Firebase (FCM) and OneSignal. - **Remote Provider Switching:** The primary notification provider is selected via remote configuration, allowing you to switch services on the fly without shipping an app update. - **Intelligent Deep-Linking:** Tapping a notification opens the app and navigates directly to the relevant content, such as a specific news article, providing a seamless user experience. -- **Foreground Notification Handling:** Displays a subtle in-app indicator when a notification arrives while the user is active, avoiding intrusive alerts. +- **Integrated Notification Center:** Includes a full-featured in-app notification center where users can view their history. Foreground notifications are handled gracefully, appearing as an unread indicator that leads the user to this central hub, avoiding intrusive system alerts during active use. > **Your Advantage:** You get a highly flexible and scalable notification system that avoids vendor lock-in and is ready to re-engage users from day one. diff --git a/lib/account/bloc/in_app_notification_center_bloc.dart b/lib/account/bloc/in_app_notification_center_bloc.dart new file mode 100644 index 00000000..d840620e --- /dev/null +++ b/lib/account/bloc/in_app_notification_center_bloc.dart @@ -0,0 +1,283 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:collection/collection.dart'; +import 'package:core/core.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; +import 'package:logging/logging.dart'; + +part 'in_app_notification_center_event.dart'; +part 'in_app_notification_center_state.dart'; + +/// {@template in_app_notification_center_bloc} +/// Manages the state for the in-app notification center. +/// +/// This BLoC is responsible for fetching the user's notifications, +/// handling actions to mark them as read individually or in bulk, and +/// coordinating with the global [AppBloc] to update the unread status +/// indicator across the app. +/// {@endtemplate} +class InAppNotificationCenterBloc + extends Bloc { + /// {@macro in_app_notification_center_bloc} + InAppNotificationCenterBloc({ + required DataRepository inAppNotificationRepository, + required AppBloc appBloc, + required Logger logger, + }) : _inAppNotificationRepository = inAppNotificationRepository, + _appBloc = appBloc, + _logger = logger, + super(const InAppNotificationCenterState()) { + on(_onSubscriptionRequested); + on(_onMarkedAsRead); + on(_onMarkAllAsRead); + on(_onTabChanged); + on(_onMarkOneAsRead); + } + + final DataRepository _inAppNotificationRepository; + final AppBloc _appBloc; + final Logger _logger; + + /// Handles the request to load all notifications for the current user. + 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.'); + emit(state.copyWith(status: InAppNotificationCenterStatus.failure)); + return; + } + + try { + final response = await _inAppNotificationRepository.readAll( + userId: userId, + sort: [const SortOption('createdAt', SortOrder.desc)], + ); + + 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); + } + } + + emit( + state.copyWith( + status: InAppNotificationCenterStatus.success, + breakingNewsNotifications: breakingNews, + digestNotifications: digests, + ), + ); + } 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, + ); + emit( + state.copyWith( + status: InAppNotificationCenterStatus.failure, + error: UnknownException(e.toString()), + ), + ); + } + } + + /// Handles the event to change the active tab. + Future _onTabChanged( + InAppNotificationCenterTabChanged event, + Emitter emit, + ) async { + emit(state.copyWith(currentTabIndex: event.tabIndex)); + } + + /// Handles marking a single notification as read. + Future _onMarkedAsRead( + InAppNotificationCenterMarkedAsRead event, + Emitter emit, + ) async { + final notification = state.notifications.firstWhereOrNull( + (n) => n.id == event.notificationId, + ); + + await _markOneAsRead(notification, emit); + } + + /// Handles marking a single notification as read from a deep-link. + Future _onMarkOneAsRead( + InAppNotificationCenterMarkOneAsRead event, + Emitter emit, + ) async { + final notification = state.notifications.firstWhereOrNull( + (n) => n.id == event.notificationId, + ); + + if (notification == null) { + _logger.warning( + 'Attempted to mark a notification as read that does not exist in the ' + 'current state: ${event.notificationId}', + ); + return; + } + + // If already read, do nothing. + if (notification.isRead) return; + + await _markOneAsRead(notification, emit); + } + + /// A shared helper method to mark a single notification as read. + /// + /// This is used by both [_onMarkedAsRead] (from the notification center UI) + /// and [_onMarkOneAsRead] (from a deep-link). + Future _markOneAsRead( + InAppNotification? notification, + Emitter emit, + ) async { + if (notification == null) return; + final updatedNotification = notification.copyWith(readAt: DateTime.now()); + + try { + await _inAppNotificationRepository.update( + id: notification.id, + item: updatedNotification, + userId: _appBloc.state.user?.id, + ); + + // Update the local state to reflect the change immediately. + final updatedBreakingNewsList = state.breakingNewsNotifications + .map((n) => n.id == notification.id ? updatedNotification : n) + .toList(); + + final updatedDigestList = state.digestNotifications + .map((n) => n.id == notification.id ? updatedNotification : n) + .toList(); + + emit( + state.copyWith( + breakingNewsNotifications: updatedBreakingNewsList, + digestNotifications: updatedDigestList, + ), + ); + + // Notify the global AppBloc to re-check the unread count. + _appBloc.add(const AppInAppNotificationMarkedAsRead()); + } on HttpException catch (e, s) { + _logger.severe( + 'Failed to mark notification ${notification.id} as read.', + e, + s, + ); + emit( + state.copyWith(status: InAppNotificationCenterStatus.failure, error: e), + ); + // Do not revert state to avoid UI flicker. The error is logged. + } catch (e, s) { + _logger.severe( + 'An unexpected error occurred while marking notification as read.', + e, + s, + ); + emit( + state.copyWith( + status: InAppNotificationCenterStatus.failure, + error: UnknownException(e.toString()), + ), + ); + } + } + + /// Handles marking all unread notifications as read. + Future _onMarkAllAsRead( + InAppNotificationCenterMarkAllAsRead event, + Emitter emit, + ) async { + final unreadNotifications = state.notifications + .where((n) => !n.isRead) + .toList(); + + if (unreadNotifications.isEmpty) return; + + final now = DateTime.now(); + final updatedNotifications = unreadNotifications + .map((n) => n.copyWith(readAt: now)) + .toList(); + + try { + // Perform all updates in parallel. + await Future.wait( + updatedNotifications.map( + (n) => _inAppNotificationRepository.update( + id: n.id, + item: n, + userId: _appBloc.state.user?.id, + ), + ), + ); + + // Update local state with all notifications marked as read. + final fullyUpdatedBreakingNewsList = state.breakingNewsNotifications + .map((n) => n.isRead ? n : n.copyWith(readAt: now)) + .toList(); + + final fullyUpdatedDigestList = state.digestNotifications + .map((n) => n.isRead ? n : n.copyWith(readAt: now)) + .toList(); + emit( + state.copyWith( + breakingNewsNotifications: fullyUpdatedBreakingNewsList, + digestNotifications: fullyUpdatedDigestList, + ), + ); + + // Notify the global AppBloc to clear the unread indicator. + _appBloc.add(const AppAllInAppNotificationsMarkedAsRead()); + } on HttpException catch (e, s) { + _logger.severe('Failed to mark all notifications as read.', e, s); + emit( + state.copyWith(status: InAppNotificationCenterStatus.failure, error: e), + ); + } catch (e, s) { + _logger.severe( + 'An unexpected error occurred while marking all notifications as read.', + e, + s, + ); + emit( + state.copyWith( + status: InAppNotificationCenterStatus.failure, + error: UnknownException(e.toString()), + ), + ); + } + } +} diff --git a/lib/account/bloc/in_app_notification_center_event.dart b/lib/account/bloc/in_app_notification_center_event.dart new file mode 100644 index 00000000..f44ca1e7 --- /dev/null +++ b/lib/account/bloc/in_app_notification_center_event.dart @@ -0,0 +1,57 @@ +part of 'in_app_notification_center_bloc.dart'; + +/// Base class for all events in the [InAppNotificationCenterBloc]. +abstract class InAppNotificationCenterEvent extends Equatable { + const InAppNotificationCenterEvent(); + + @override + List get props => []; +} + +/// Dispatched when the notification center is opened and needs to load +/// the initial list of notifications. +class InAppNotificationCenterSubscriptionRequested + extends InAppNotificationCenterEvent { + const InAppNotificationCenterSubscriptionRequested(); +} + +/// Dispatched when a single in-app notification is marked as read. +class InAppNotificationCenterMarkedAsRead extends InAppNotificationCenterEvent { + const InAppNotificationCenterMarkedAsRead(this.notificationId); + + /// The ID of the notification to be marked as read. + final String notificationId; + + @override + List get props => [notificationId]; +} + +/// Dispatched when the user requests to mark all notifications as read. +class InAppNotificationCenterMarkAllAsRead + extends InAppNotificationCenterEvent { + const InAppNotificationCenterMarkAllAsRead(); +} + +/// Dispatched when the user changes the selected tab in the notification center. +class InAppNotificationCenterTabChanged extends InAppNotificationCenterEvent { + const InAppNotificationCenterTabChanged(this.tabIndex); + + /// The index of the newly selected tab. 0: Breaking News, 1: Digests. + final int tabIndex; + + @override + List get props => [tabIndex]; +} + +/// Dispatched when a single in-app notification is marked as read by its ID, +/// typically from a deep-link without navigating from the notification center. +class InAppNotificationCenterMarkOneAsRead + extends InAppNotificationCenterEvent { + const InAppNotificationCenterMarkOneAsRead(this.notificationId); + + /// The ID of the notification to be marked as read. + final String notificationId; + + @override + List get props => [notificationId]; +} diff --git a/lib/account/bloc/in_app_notification_center_state.dart b/lib/account/bloc/in_app_notification_center_state.dart new file mode 100644 index 00000000..ded68a2e --- /dev/null +++ b/lib/account/bloc/in_app_notification_center_state.dart @@ -0,0 +1,80 @@ +part of 'in_app_notification_center_bloc.dart'; + +/// The status of the [InAppNotificationCenterBloc]. +enum InAppNotificationCenterStatus { + /// The initial state. + initial, + + /// The state when notifications are being loaded. + loading, + + /// The state when notifications have been successfully loaded. + success, + + /// The state when an error has occurred. + failure, +} + +/// {@template in_app_notification_center_state} +/// The state of the in-app notification center. +/// {@endtemplate} +class InAppNotificationCenterState extends Equatable { + /// {@macro in_app_notification_center_state} + const InAppNotificationCenterState({ + this.status = InAppNotificationCenterStatus.initial, + this.breakingNewsNotifications = const [], + this.digestNotifications = const [], + this.currentTabIndex = 0, + this.error, + }); + + /// The currently selected tab index. + /// 0: Breaking News, 1: Digests. + final int currentTabIndex; + + /// The list of breaking news notifications. + final List breakingNewsNotifications; + + /// The list of digest notifications (daily and weekly roundups). + final List digestNotifications; + + /// The current status of the notification center. + final InAppNotificationCenterStatus status; + + /// The combined list of all notifications. + List get notifications => [ + ...breakingNewsNotifications, + ...digestNotifications, + ]; + + /// An error that occurred during notification loading or processing. + final HttpException? error; + + @override + List get props => [ + status, + currentTabIndex, + breakingNewsNotifications, + digestNotifications, + error ?? Object(), // Include error in props, handle nullability + ]; + + /// Creates a copy of this state with the given fields replaced with the new + /// values. + InAppNotificationCenterState copyWith({ + InAppNotificationCenterStatus? status, + HttpException? error, + int? currentTabIndex, + List? breakingNewsNotifications, + List? digestNotifications, + }) { + return InAppNotificationCenterState( + status: status ?? this.status, + error: error ?? this.error, + currentTabIndex: currentTabIndex ?? this.currentTabIndex, + breakingNewsNotifications: + breakingNewsNotifications ?? this.breakingNewsNotifications, + digestNotifications: digestNotifications ?? this.digestNotifications, + ); + } +} diff --git a/lib/account/view/account_page.dart b/lib/account/view/account_page.dart index b5aaa909..36a2bc23 100644 --- a/lib/account/view/account_page.dart +++ b/lib/account/view/account_page.dart @@ -5,7 +5,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_blo import 'package:flutter_news_app_mobile_client_full_source_code/app/models/app_life_cycle_status.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/shared/widgets/user_avatar.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/shared/shared.dart'; import 'package:go_router/go_router.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -168,38 +168,63 @@ class AccountPage extends StatelessWidget { /// account-related sections. Widget _buildNavigationList(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; - final theme = Theme.of(context); - final textTheme = theme.textTheme; - - // Helper to create a ListTile with consistent styling. - Widget buildTile({ - required IconData icon, - required String title, - required VoidCallback onTap, - }) { - return ListTile( - leading: Icon(icon, color: theme.colorScheme.primary), - title: Text(title, style: textTheme.titleMedium), - trailing: const Icon(Icons.chevron_right), - onTap: onTap, - ); - } - return Column( children: [ buildTile( - icon: Icons.tune_outlined, + context: context, + icon: Icons.check_outlined, title: l10n.accountContentPreferencesTile, onTap: () => context.pushNamed(Routes.manageFollowedItemsName), ), const Divider(), buildTile( + context: context, icon: Icons.bookmark_outline, title: l10n.accountSavedHeadlinesTile, onTap: () => context.pushNamed(Routes.accountSavedHeadlinesName), ), const Divider(), + BlocSelector( + selector: (state) => state.hasUnreadInAppNotifications, + builder: (context, showIndicator) { + return buildTile( + context: context, + icon: Icons.notifications_none_outlined, + title: l10n.accountNotificationsTile, + onTap: () { + // Navigate to the new Notification Center page. + context.pushNamed(Routes.notificationsCenterName); + }, + // Wrap the title with NotificationIndicator to show the red dot. + // This ensures the indicator is aligned with the text. + showIndicator: showIndicator, + ); + }, + ), + const Divider(), ], ); } + + /// Helper to create a ListTile with consistent styling and optional indicator. + Widget buildTile({ + required BuildContext context, + required IconData icon, + required String title, + required VoidCallback onTap, + bool showIndicator = false, + }) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + + return ListTile( + leading: Icon(icon, color: theme.colorScheme.primary), + title: NotificationIndicator( + showIndicator: showIndicator, + child: Text(title, style: textTheme.titleMedium), + ), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ); + } } diff --git a/lib/account/view/in_app_notification_center_page.dart b/lib/account/view/in_app_notification_center_page.dart new file mode 100644 index 00000000..e8d8af8a --- /dev/null +++ b/lib/account/view/in_app_notification_center_page.dart @@ -0,0 +1,193 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/in_app_notification_center_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/account/widgets/in_app_notification_list_item.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/ads/services/interstitial_ad_manager.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template in_app_notification_center_page} +/// A page that displays a chronological list of all in-app notifications. +/// +/// This page allows users to view their notification history, mark individual +/// notifications as read, and mark all notifications as read. +/// {@endtemplate} +class InAppNotificationCenterPage extends StatefulWidget { + /// {@macro in_app_notification_center_page} + const InAppNotificationCenterPage({super.key}); + + @override + State createState() => + _InAppNotificationCenterPageState(); +} + +class _InAppNotificationCenterPageState + extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this) + ..addListener(() { + if (!_tabController.indexIsChanging) { + context.read().add( + InAppNotificationCenterTabChanged(_tabController.index), + ); + } + }); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.notificationCenterPageTitle), + actions: [ + BlocBuilder< + InAppNotificationCenterBloc, + InAppNotificationCenterState + >( + builder: (context, state) { + final hasUnread = state.notifications.any((n) => !n.isRead); + return IconButton( + onPressed: hasUnread + ? () { + context.read().add( + const InAppNotificationCenterMarkAllAsRead(), + ); + } + : null, + icon: const Icon(Icons.done_all), + ); + }, + ), + ], + bottom: TabBar( + controller: _tabController, + tabs: [ + Tab(text: l10n.notificationCenterTabBreakingNews), + Tab(text: l10n.notificationCenterTabDigests), + ], + ), + ), + body: + BlocConsumer< + InAppNotificationCenterBloc, + InAppNotificationCenterState + >( + listener: (context, state) { + if (state.status == InAppNotificationCenterStatus.failure && + state.error != null) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(state.error!.message), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + }, + builder: (context, state) { + if (state.status == InAppNotificationCenterStatus.loading) { + return LoadingStateWidget( + icon: Icons.notifications_none_outlined, + headline: l10n.notificationCenterLoadingHeadline, + subheadline: l10n.notificationCenterLoadingSubheadline, + ); + } + + if (state.status == InAppNotificationCenterStatus.failure) { + return FailureStateWidget( + exception: + state.error ?? + OperationFailedException( + l10n.notificationCenterFailureHeadline, + ), + onRetry: () { + context.read().add( + const InAppNotificationCenterSubscriptionRequested(), + ); + }, + ); + } + + return TabBarView( + controller: _tabController, + children: [ + _NotificationList( + notifications: state.breakingNewsNotifications, + ), + _NotificationList(notifications: state.digestNotifications), + ], + ); + }, + ), + ); + } +} + +class _NotificationList extends StatelessWidget { + const _NotificationList({required this.notifications}); + + final List notifications; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + + if (notifications.isEmpty) { + return InitialStateWidget( + icon: Icons.notifications_off_outlined, + headline: l10n.notificationCenterEmptyHeadline, + subheadline: l10n.notificationCenterEmptySubheadline, + ); + } + + return ListView.separated( + itemCount: notifications.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final notification = notifications[index]; + return InAppNotificationListItem( + notification: notification, + onTap: () async { + context.read().add( + InAppNotificationCenterMarkedAsRead(notification.id), + ); + + final payload = notification.payload; + final contentType = payload.data['contentType'] as String?; + final id = payload.data['headlineId'] as String?; + + if (contentType == 'headline' && id != null) { + await context + .read() + .onPotentialAdTrigger(); + + if (!context.mounted) return; + + await context.pushNamed( + Routes.globalArticleDetailsName, + pathParameters: {'id': id}, + ); + } + }, + ); + }, + ); + } +} diff --git a/lib/account/widgets/in_app_notification_list_item.dart b/lib/account/widgets/in_app_notification_list_item.dart new file mode 100644 index 00000000..a5fcabd8 --- /dev/null +++ b/lib/account/widgets/in_app_notification_list_item.dart @@ -0,0 +1,72 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:timeago/timeago.dart' as timeago; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template in_app_notification_list_item} +/// A widget that displays a single in-app notification in a list. +/// +/// It shows the notification's title, body, and the time it was received. +/// Unread notifications are visually distinguished with a leading dot and +/// a bolder title. +/// {@endtemplate} +class InAppNotificationListItem extends StatelessWidget { + /// {@macro in_app_notification_list_item} + const InAppNotificationListItem({ + required this.notification, + required this.onTap, + super.key, + }); + + /// The notification to display. + final InAppNotification notification; + + /// The callback that is executed when the list item is tapped. + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final isUnread = !notification.isRead; + + return ListTile( + leading: isUnread + ? Padding( + padding: const EdgeInsets.only(top: AppSpacing.sm), + child: Container( + width: AppSpacing.sm, + height: AppSpacing.sm, + decoration: BoxDecoration( + color: theme.colorScheme.primary, + shape: BoxShape.circle, + ), + ), + ) + : const SizedBox(width: AppSpacing.sm), + title: Text( + notification.payload.title, + style: textTheme.titleMedium?.copyWith( + fontWeight: isUnread ? FontWeight.bold : FontWeight.normal, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + notification.payload.body, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: AppSpacing.xs), + Text( + timeago.format(notification.createdAt), + style: textTheme.bodySmall, + ), + ], + ), + onTap: onTap, + isThreeLine: true, + ); + } +} diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 1f07549c..a262dbd0 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -47,12 +47,14 @@ class AppBloc extends Bloc { required Logger logger, required DataRepository userRepository, required PushNotificationService pushNotificationService, + required DataRepository inAppNotificationRepository, }) : _remoteConfigRepository = remoteConfigRepository, _appInitializer = appInitializer, _authRepository = authRepository, _userAppSettingsRepository = userAppSettingsRepository, _userContentPreferencesRepository = userContentPreferencesRepository, _userRepository = userRepository, + _inAppNotificationRepository = inAppNotificationRepository, _pushNotificationService = pushNotificationService, _inlineAdCacheService = inlineAdCacheService, _logger = logger, @@ -85,9 +87,13 @@ class AppBloc extends Bloc { on( _onAppPushNotificationDeviceRegistered, ); - on(_onAppInAppNotificationReceived); on(_onLogoutRequested); on(_onAppPushNotificationTokenRefreshed); + on(_onAppInAppNotificationReceived); + on( + _onAllInAppNotificationsMarkedAsRead, + ); + on(_onInAppNotificationMarkedAsRead); // Listen to token refresh events from the push notification service. // When a token is refreshed, dispatch an event to trigger device @@ -95,6 +101,15 @@ class AppBloc extends Bloc { _pushNotificationService.onTokenRefreshed.listen((_) { add(const AppPushNotificationTokenRefreshed()); }); + + // Listen to raw foreground push notifications. + _pushNotificationService.onMessage.listen((payload) async { + _logger.fine('AppBloc received foreground push notification payload.'); + // The backend now persists the notification when it sends the push. The + // client's only responsibility is to react to the incoming message + // and update the UI to show an unread indicator. + add(const AppInAppNotificationReceived()); + }); } final Logger _logger; @@ -105,6 +120,7 @@ class AppBloc extends Bloc { final DataRepository _userContentPreferencesRepository; final DataRepository _userRepository; + final DataRepository _inAppNotificationRepository; final PushNotificationService _pushNotificationService; final InlineAdCacheService _inlineAdCacheService; @@ -625,6 +641,48 @@ class AppBloc extends Bloc { emit(state.copyWith(hasUnreadInAppNotifications: true)); } + /// Handles the [AppAllInAppNotificationsMarkedAsRead] event. + /// + /// This handler is responsible for resetting the global unread notification + /// indicator when all notifications have been marked as read. + Future _onAllInAppNotificationsMarkedAsRead( + AppAllInAppNotificationsMarkedAsRead event, + Emitter emit, + ) async { + // After marking all as read, we can confidently set the flag to false. + emit(state.copyWith(hasUnreadInAppNotifications: false)); + } + + /// Handles the [AppInAppNotificationMarkedAsRead] event. + /// + /// This handler checks if there are any remaining unread notifications after + /// one has been marked as read. If no unread notifications are left, it + /// resets the global unread indicator. + Future _onInAppNotificationMarkedAsRead( + AppInAppNotificationMarkedAsRead event, + Emitter emit, + ) async { + if (state.user == null) return; + + try { + final unreadCount = await _inAppNotificationRepository.count( + userId: state.user!.id, + filter: {'readAt': null}, + ); + + if (unreadCount == 0) { + emit(state.copyWith(hasUnreadInAppNotifications: false)); + } + } catch (e, s) { + _logger.severe( + 'Failed to check for remaining unread notifications.', + e, + s, + ); + // Do not change state on error to avoid inconsistent UI. + } + } + /// Handles the [AppPushNotificationTokenRefreshed] event. /// /// This event is triggered when the underlying push notification provider diff --git a/lib/app/bloc/app_event.dart b/lib/app/bloc/app_event.dart index 500b156a..70f3eb28 100644 --- a/lib/app/bloc/app_event.dart +++ b/lib/app/bloc/app_event.dart @@ -217,3 +217,21 @@ class AppPushNotificationTokenRefreshed extends AppEvent { /// {@macro app_push_notification_token_refreshed} const AppPushNotificationTokenRefreshed(); } + +/// {@template app_in_app_notification_marked_as_read} +/// Dispatched when a single in-app notification is marked as read. +/// {@endtemplate} +class AppInAppNotificationMarkedAsRead extends AppEvent { + /// {@macro app_in_app_notification_marked_as_read} + const AppInAppNotificationMarkedAsRead(); +} + +/// {@template app_all_in_app_notifications_marked_as_read} +/// Dispatched when all in-app notifications are marked as read. +/// +/// This event is used to clear the global unread notification indicator. +/// {@endtemplate} +class AppAllInAppNotificationsMarkedAsRead extends AppEvent { + /// {@macro app_all_in_app_notifications_marked_as_read} + const AppAllInAppNotificationsMarkedAsRead(); +} diff --git a/lib/app/services/demo_data_initializer_service.dart b/lib/app/services/demo_data_initializer_service.dart index ca5df0ad..7896f985 100644 --- a/lib/app/services/demo_data_initializer_service.dart +++ b/lib/app/services/demo_data_initializer_service.dart @@ -22,15 +22,19 @@ class DemoDataInitializerService { required DataRepository userAppSettingsRepository, required DataRepository userContentPreferencesRepository, + required DataRepository inAppNotificationRepository, required this.userAppSettingsFixturesData, required this.userContentPreferencesFixturesData, + required this.inAppNotificationsFixturesData, }) : _userAppSettingsRepository = userAppSettingsRepository, _userContentPreferencesRepository = userContentPreferencesRepository, + _inAppNotificationRepository = inAppNotificationRepository, _logger = Logger('DemoDataInitializerService'); final DataRepository _userAppSettingsRepository; final DataRepository _userContentPreferencesRepository; + final DataRepository _inAppNotificationRepository; final Logger _logger; /// A list of [UserAppSettings] fixture data to be used as a template. @@ -43,6 +47,11 @@ class DemoDataInitializerService { /// The first item in this list will be cloned for new users. final List userContentPreferencesFixturesData; + /// A list of [InAppNotification] fixture data to be used as a template. + /// + /// All items in this list will be cloned for new users. + final List inAppNotificationsFixturesData; + /// Initializes essential user-specific data in the in-memory clients /// for the given [user]. /// @@ -59,6 +68,7 @@ class DemoDataInitializerService { await Future.wait([ _ensureUserAppSettingsExist(user.id), _ensureUserContentPreferencesExist(user.id), + _ensureInAppNotificationsExist(user.id), ]); _logger.info( @@ -146,4 +156,59 @@ class DemoDataInitializerService { rethrow; } } + + /// Ensures that [InAppNotification]s exist for the given [userId]. + /// + /// This method clones all notifications from the fixture data, assigns the + /// new user's ID to each one, and creates them in the in-memory repository. + /// This provides new demo users with a pre-populated notification center. + Future _ensureInAppNotificationsExist(String userId) async { + try { + // Check if notifications already exist for this user. + final existingNotifications = await _inAppNotificationRepository.readAll( + userId: userId, + ); + if (existingNotifications.items.isNotEmpty) { + _logger.info('InAppNotifications already exist for user ID: $userId.'); + return; + } + + _logger.info( + 'No InAppNotifications found for user ID: $userId. Creating from fixture.', + ); + + if (inAppNotificationsFixturesData.isEmpty) { + _logger.warning( + 'inAppNotificationsFixturesData is empty. No notifications to create.', + ); + return; + } + + // Exclude the first notification, which will be used for the simulated push. + final notificationsToCreate = inAppNotificationsFixturesData + .skip(1) + .toList(); + + final userNotifications = notificationsToCreate + .map((n) => n.copyWith(userId: userId)) + .toList(); + + await Future.wait( + userNotifications.map( + (n) => _inAppNotificationRepository.create(item: n, userId: userId), + ), + ); + _logger.info( + '${userNotifications.length} InAppNotifications from fixture created for user ID: $userId.', + ); + } catch (e, s) { + _logger.severe( + 'Error ensuring InAppNotifications exist for user ID: $userId: $e', + e, + s, + ); + // We don't rethrow here as failing to create notifications + // is not a critical failure for the app's startup. + } + } } diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 187104b9..c393ad0b 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -52,6 +52,7 @@ class App extends StatelessWidget { required DataRepository userContentPreferencesRepository, required AppEnvironment environment, + required DataRepository inAppNotificationRepository, required InlineAdCacheService inlineAdCacheService, required AdService adService, required FeedDecoratorService feedDecoratorService, @@ -69,6 +70,7 @@ class App extends StatelessWidget { _userAppSettingsRepository = userAppSettingsRepository, _userContentPreferencesRepository = userContentPreferencesRepository, _pushNotificationService = pushNotificationService, + _inAppNotificationRepository = inAppNotificationRepository, _environment = environment, _adService = adService, _feedDecoratorService = feedDecoratorService, @@ -99,6 +101,7 @@ class App extends StatelessWidget { final DataRepository _userContentPreferencesRepository; final AppEnvironment _environment; + final DataRepository _inAppNotificationRepository; final AdService _adService; final FeedDecoratorService _feedDecoratorService; final FeedCacheService _feedCacheService; @@ -125,6 +128,7 @@ class App extends StatelessWidget { RepositoryProvider.value(value: _userAppSettingsRepository), RepositoryProvider.value(value: _userContentPreferencesRepository), RepositoryProvider.value(value: _pushNotificationService), + RepositoryProvider.value(value: _inAppNotificationRepository), RepositoryProvider.value(value: _inlineAdCacheService), RepositoryProvider.value(value: _feedCacheService), RepositoryProvider.value(value: _environment), @@ -148,6 +152,7 @@ class App extends StatelessWidget { _userContentPreferencesRepository, logger: context.read(), pushNotificationService: _pushNotificationService, + inAppNotificationRepository: _inAppNotificationRepository, userRepository: _userRepository, inlineAdCacheService: _inlineAdCacheService, )..add(const AppStarted()), @@ -217,17 +222,19 @@ class _AppViewState extends State<_AppView> { final contentType = payload.data['contentType'] as String?; // e.g., 'headline' final id = payload.data['headlineId'] as String?; + final notificationId = payload.data['notificationId'] as String?; if (contentType == 'headline' && id != null) { // Use pushNamed instead of goNamed. // goNamed replaces the entire navigation stack, which causes issues // when the app is launched from a terminated state. The new page // would lack the necessary ancestor widgets (like RepositoryProviders). - // pushNamed correctly pushes the details page on top of the - // existing stack (e.g., the feed), ensuring a valid context. + // pushNamed correctly pushes the details page on top of the existing + // stack (e.g., the feed), ensuring a valid context. _router.pushNamed( Routes.globalArticleDetailsName, pathParameters: {'id': id}, + extra: {'notificationId': notificationId}, ); } }); diff --git a/lib/app/view/app_initialization_page.dart b/lib/app/view/app_initialization_page.dart index deec5e88..267dc245 100644 --- a/lib/app/view/app_initialization_page.dart +++ b/lib/app/view/app_initialization_page.dart @@ -57,6 +57,7 @@ class AppInitializationPage extends StatelessWidget { required this.inlineAdCacheService, required this.navigatorKey, required this.pushNotificationService, + required this.inAppNotificationRepository, super.key, }); @@ -76,6 +77,7 @@ class AppInitializationPage extends StatelessWidget { final GlobalKey navigatorKey; final InlineAdCacheService inlineAdCacheService; final PushNotificationService pushNotificationService; + final DataRepository inAppNotificationRepository; @override Widget build(BuildContext context) { @@ -112,6 +114,7 @@ class AppInitializationPage extends StatelessWidget { userContentPreferencesRepository, environment: environment, pushNotificationService: pushNotificationService, + inAppNotificationRepository: inAppNotificationRepository, adService: adService, feedDecoratorService: feedDecoratorService, feedCacheService: feedCacheService, diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index bb262fd2..2e462ee6 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -215,6 +215,7 @@ Future bootstrap( late final DataClient userContentPreferencesClient; late final DataClient userAppSettingsClient; late final DataClient userClient; + late final DataClient inAppNotificationClient; late final DataClient pushNotificationDeviceClient; if (appConfig.environment == app_config.AppEnvironment.demo) { logger.fine('Using in-memory clients for all data repositories.'); @@ -285,6 +286,11 @@ Future bootstrap( getId: (i) => i.id, logger: logger, ); + inAppNotificationClient = DataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + logger: logger, + ); pushNotificationDeviceClient = DataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, @@ -341,6 +347,13 @@ Future bootstrap( toJson: (user) => user.toJson(), logger: logger, ); + inAppNotificationClient = DataApi( + httpClient: httpClient, + modelName: 'in_app_notification', + fromJson: InAppNotification.fromJson, + toJson: (notification) => notification.toJson(), + logger: logger, + ); pushNotificationDeviceClient = DataApi( httpClient: httpClient, modelName: 'push_notification_device', @@ -400,6 +413,13 @@ Future bootstrap( toJson: (user) => user.toJson(), logger: logger, ); + inAppNotificationClient = DataApi( + httpClient: httpClient, + modelName: 'in_app_notification', + fromJson: InAppNotification.fromJson, + toJson: (notification) => notification.toJson(), + logger: logger, + ); pushNotificationDeviceClient = DataApi( httpClient: httpClient, modelName: 'push_notification_device', @@ -426,6 +446,9 @@ Future bootstrap( dataClient: userAppSettingsClient, ); final userRepository = DataRepository(dataClient: userClient); + final inAppNotificationRepository = DataRepository( + dataClient: inAppNotificationClient, + ); final pushNotificationDeviceRepository = DataRepository( dataClient: pushNotificationDeviceClient, @@ -447,7 +470,11 @@ Future bootstrap( // allowing the full UI journey to be tested without a real backend. if (appConfig.environment == app_config.AppEnvironment.demo) { logger.fine('Using NoOpPushNotificationService for demo environment.'); - pushNotificationService = NoOpPushNotificationService(); + pushNotificationService = NoOpPushNotificationService( + inAppNotificationRepository: inAppNotificationRepository, + inAppNotificationsFixturesData: inAppNotificationsFixturesData, + environment: appConfig.environment, + ); } else if (pushNotificationConfig.enabled == true) { // For other environments, select the provider based on RemoteConfig. switch (pushNotificationConfig.primaryProvider) { @@ -472,7 +499,11 @@ Future bootstrap( logger.warning('Push notifications are disabled in RemoteConfig.'); // Provide a dummy/no-op implementation if notifications are disabled // to prevent null pointer exceptions when accessed. - pushNotificationService = NoOpPushNotificationService(); + pushNotificationService = NoOpPushNotificationService( + inAppNotificationRepository: inAppNotificationRepository, + inAppNotificationsFixturesData: inAppNotificationsFixturesData, + environment: appConfig.environment, + ); } // Conditionally instantiate DemoDataMigrationService @@ -497,9 +528,11 @@ Future bootstrap( ? DemoDataInitializerService( userAppSettingsRepository: userAppSettingsRepository, userContentPreferencesRepository: userContentPreferencesRepository, + inAppNotificationRepository: inAppNotificationRepository, userAppSettingsFixturesData: userAppSettingsFixturesData, userContentPreferencesFixturesData: userContentPreferencesFixturesData, + inAppNotificationsFixturesData: inAppNotificationsFixturesData, ) : null; logger @@ -548,6 +581,7 @@ Future bootstrap( userAppSettingsRepository: userAppSettingsRepository, userContentPreferencesRepository: userContentPreferencesRepository, pushNotificationService: pushNotificationService, + inAppNotificationRepository: inAppNotificationRepository, environment: environment, adService: adService, feedDecoratorService: feedDecoratorService, diff --git a/lib/headline-details/view/headline_details_page.dart b/lib/headline-details/view/headline_details_page.dart index 4b783557..06c426da 100644 --- a/lib/headline-details/view/headline_details_page.dart +++ b/lib/headline-details/view/headline_details_page.dart @@ -5,6 +5,7 @@ import 'package:core/core.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/in_app_notification_center_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_theme_style.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/services/interstitial_ad_manager.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/widgets/in_article_ad_loader_widget.dart'; @@ -23,11 +24,17 @@ import 'package:ui_kit/ui_kit.dart'; import 'package:url_launcher/url_launcher_string.dart'; class HeadlineDetailsPage extends StatefulWidget { - const HeadlineDetailsPage({super.key, this.headlineId, this.initialHeadline}) - : assert(headlineId != null || initialHeadline != null); + const HeadlineDetailsPage({ + super.key, + this.headlineId, + this.initialHeadline, + this.notificationId, + }) : assert(headlineId != null || initialHeadline != null); final String? headlineId; final Headline? initialHeadline; + // The ID of the in-app notification that triggered this navigation. + final String? notificationId; @override State createState() => _HeadlineDetailsPageState(); @@ -52,6 +59,15 @@ class _HeadlineDetailsPageState extends State { FetchHeadlineById(widget.headlineId!), ); } + + // If a notificationId is provided, it means the user deep-linked from a + // push notification. We dispatch an event to mark that specific + // notification as read. + if (widget.notificationId != null) { + context.read().add( + InAppNotificationCenterMarkOneAsRead(widget.notificationId!), + ); + } } @override diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index cee81838..cb26e1b2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -215,7 +215,7 @@ abstract class AppLocalizations { /// Title for the content preferences navigation tile in the account page /// /// In en, this message translates to: - /// **'Content Preferences'** + /// **'Followed content'** String get accountContentPreferencesTile; /// Title for the saved headlines navigation tile in the account page @@ -2461,6 +2461,66 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Notification permission was denied. You can enable it in your device settings.'** String get notificationPermissionDeniedError; + + /// Title for the in-app notification center page. + /// + /// In en, this message translates to: + /// **'Notifications'** + String get notificationCenterPageTitle; + + /// Text for the button to mark all notifications as read. + /// + /// In en, this message translates to: + /// **'Mark all as read'** + String get notificationCenterMarkAllAsReadButton; + + /// Headline for the loading state on the notification center page. + /// + /// In en, this message translates to: + /// **'Loading Notifications...'** + String get notificationCenterLoadingHeadline; + + /// Subheadline for the loading state on the notification center page. + /// + /// In en, this message translates to: + /// **'Please wait...'** + String get notificationCenterLoadingSubheadline; + + /// Headline for the failure state on the notification center page. + /// + /// In en, this message translates to: + /// **'Failed to Load Notifications'** + String get notificationCenterFailureHeadline; + + /// Subheadline for the failure state on the notification center page. + /// + /// In en, this message translates to: + /// **'Could not retrieve your notifications. Please try again.'** + String get notificationCenterFailureSubheadline; + + /// Headline for the empty state on the notification center page. + /// + /// In en, this message translates to: + /// **'No Notifications'** + String get notificationCenterEmptyHeadline; + + /// Subheadline for the empty state on the notification center page. + /// + /// In en, this message translates to: + /// **'You have no new notifications.'** + String get notificationCenterEmptySubheadline; + + /// Label for the 'Breaking News' tab in the notification center. + /// + /// In en, this message translates to: + /// **'Breaking News'** + String get notificationCenterTabBreakingNews; + + /// Label for the 'Digests' tab in the notification center. + /// + /// In en, this message translates to: + /// **'Digests'** + String get notificationCenterTabDigests; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 33634601..572c9e6b 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -69,10 +69,10 @@ class AppLocalizationsAr extends AppLocalizations { String get accountBackupTile => 'أنشئ حسابًا لحفظ البيانات'; @override - String get accountContentPreferencesTile => 'تفضيلات المحتوى'; + String get accountContentPreferencesTile => 'المتابعات'; @override - String get accountSavedHeadlinesTile => 'العناوين المحفوظة'; + String get accountSavedHeadlinesTile => 'المحفوظات'; @override String accountRoleLabel(String role) { @@ -1294,4 +1294,36 @@ class AppLocalizationsAr extends AppLocalizations { @override String get notificationPermissionDeniedError => 'تم رفض إذن الإشعارات. يمكنك تمكينه في إعدادات جهازك.'; + + @override + String get notificationCenterPageTitle => 'الإشعارات'; + + @override + String get notificationCenterMarkAllAsReadButton => + 'وضع علامة على الكل كمقروء'; + + @override + String get notificationCenterLoadingHeadline => 'جارٍ تحميل الإشعارات...'; + + @override + String get notificationCenterLoadingSubheadline => 'يرجى الانتظار...'; + + @override + String get notificationCenterFailureHeadline => 'فشل تحميل الإشعارات'; + + @override + String get notificationCenterFailureSubheadline => + 'تعذر استرداد إشعاراتك. يرجى المحاولة مرة أخرى.'; + + @override + String get notificationCenterEmptyHeadline => 'لا توجد إشعارات'; + + @override + String get notificationCenterEmptySubheadline => 'ليس لديك إشعارات جديدة.'; + + @override + String get notificationCenterTabBreakingNews => 'العواجل'; + + @override + String get notificationCenterTabDigests => 'الملخصات'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index dbe664fe..e0af59c4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -69,7 +69,7 @@ class AppLocalizationsEn extends AppLocalizations { String get accountBackupTile => 'Create Account to Save Data'; @override - String get accountContentPreferencesTile => 'Content Preferences'; + String get accountContentPreferencesTile => 'Followed content'; @override String get accountSavedHeadlinesTile => 'Saved Headlines'; @@ -1297,4 +1297,37 @@ class AppLocalizationsEn extends AppLocalizations { @override String get notificationPermissionDeniedError => 'Notification permission was denied. You can enable it in your device settings.'; + + @override + String get notificationCenterPageTitle => 'Notifications'; + + @override + String get notificationCenterMarkAllAsReadButton => 'Mark all as read'; + + @override + String get notificationCenterLoadingHeadline => 'Loading Notifications...'; + + @override + String get notificationCenterLoadingSubheadline => 'Please wait...'; + + @override + String get notificationCenterFailureHeadline => + 'Failed to Load Notifications'; + + @override + String get notificationCenterFailureSubheadline => + 'Could not retrieve your notifications. Please try again.'; + + @override + String get notificationCenterEmptyHeadline => 'No Notifications'; + + @override + String get notificationCenterEmptySubheadline => + 'You have no new notifications.'; + + @override + String get notificationCenterTabBreakingNews => 'Breaking News'; + + @override + String get notificationCenterTabDigests => 'Digests'; } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index f9d33e29..a0a4c621 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -76,11 +76,11 @@ "@accountBackupTile": { "description": "Title for the tile prompting anonymous users to create an account" }, - "accountContentPreferencesTile": "تفضيلات المحتوى", + "accountContentPreferencesTile": "المتابعات", "@accountContentPreferencesTile": { "description": "Title for the content preferences navigation tile in the account page" }, - "accountSavedHeadlinesTile": "العناوين المحفوظة", + "accountSavedHeadlinesTile": "المحفوظات", "@accountSavedHeadlinesTile": { "description": "Title for the saved headlines navigation tile in the account page" }, @@ -1681,5 +1681,45 @@ "notificationPermissionDeniedError": "تم رفض إذن الإشعارات. يمكنك تمكينه في إعدادات جهازك.", "@notificationPermissionDeniedError": { "description": "Error message shown when the user denies notification permissions at the OS level." + }, + "notificationCenterPageTitle": "الإشعارات", + "@notificationCenterPageTitle": { + "description": "Title for the in-app notification center page." + }, + "notificationCenterMarkAllAsReadButton": "وضع علامة على الكل كمقروء", + "@notificationCenterMarkAllAsReadButton": { + "description": "Text for the button to mark all notifications as read." + }, + "notificationCenterLoadingHeadline": "جارٍ تحميل الإشعارات...", + "@notificationCenterLoadingHeadline": { + "description": "Headline for the loading state on the notification center page." + }, + "notificationCenterLoadingSubheadline": "يرجى الانتظار...", + "@notificationCenterLoadingSubheadline": { + "description": "Subheadline for the loading state on the notification center page." + }, + "notificationCenterFailureHeadline": "فشل تحميل الإشعارات", + "@notificationCenterFailureHeadline": { + "description": "Headline for the failure state on the notification center page." + }, + "notificationCenterFailureSubheadline": "تعذر استرداد إشعاراتك. يرجى المحاولة مرة أخرى.", + "@notificationCenterFailureSubheadline": { + "description": "Subheadline for the failure state on the notification center page." + }, + "notificationCenterEmptyHeadline": "لا توجد إشعارات", + "@notificationCenterEmptyHeadline": { + "description": "Headline for the empty state on the notification center page." + }, + "notificationCenterEmptySubheadline": "ليس لديك إشعارات جديدة.", + "@notificationCenterEmptySubheadline": { + "description": "Subheadline for the empty state on the notification center page." + }, + "notificationCenterTabBreakingNews": "العواجل", + "@notificationCenterTabBreakingNews": { + "description": "Label for the 'Breaking News' tab in the notification center." + }, + "notificationCenterTabDigests": "الملخصات", + "@notificationCenterTabDigests": { + "description": "Label for the 'Digests' tab in the notification center." } } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 9183e29b..d01cab85 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -76,7 +76,7 @@ "@accountBackupTile": { "description": "Title for the tile prompting anonymous users to create an account" }, - "accountContentPreferencesTile": "Content Preferences", + "accountContentPreferencesTile": "Followed content", "@accountContentPreferencesTile": { "description": "Title for the content preferences navigation tile in the account page" }, @@ -1661,8 +1661,7 @@ "notificationDeliveryTypeWeeklyRoundup": "Weekly Roundup", "@notificationDeliveryTypeWeeklyRoundup": { "description": "The user-facing name for the 'weeklyRoundup' notification delivery type." - } - , + }, "prePermissionDialogTitle": "Enable Notifications?", "@prePermissionDialogTitle": { "description": "Title for the pre-permission dialog asking the user to enable notifications." @@ -1682,5 +1681,45 @@ "notificationPermissionDeniedError": "Notification permission was denied. You can enable it in your device settings.", "@notificationPermissionDeniedError": { "description": "Error message shown when the user denies notification permissions at the OS level." + }, + "notificationCenterPageTitle": "Notifications", + "@notificationCenterPageTitle": { + "description": "Title for the in-app notification center page." + }, + "notificationCenterMarkAllAsReadButton": "Mark all as read", + "@notificationCenterMarkAllAsReadButton": { + "description": "Text for the button to mark all notifications as read." + }, + "notificationCenterLoadingHeadline": "Loading Notifications...", + "@notificationCenterLoadingHeadline": { + "description": "Headline for the loading state on the notification center page." + }, + "notificationCenterLoadingSubheadline": "Please wait...", + "@notificationCenterLoadingSubheadline": { + "description": "Subheadline for the loading state on the notification center page." + }, + "notificationCenterFailureHeadline": "Failed to Load Notifications", + "@notificationCenterFailureHeadline": { + "description": "Headline for the failure state on the notification center page." + }, + "notificationCenterFailureSubheadline": "Could not retrieve your notifications. Please try again.", + "@notificationCenterFailureSubheadline": { + "description": "Subheadline for the failure state on the notification center page." + }, + "notificationCenterEmptyHeadline": "No Notifications", + "@notificationCenterEmptyHeadline": { + "description": "Headline for the empty state on the notification center page." + }, + "notificationCenterEmptySubheadline": "You have no new notifications.", + "@notificationCenterEmptySubheadline": { + "description": "Subheadline for the empty state on the notification center page." + }, + "notificationCenterTabBreakingNews": "Breaking News", + "@notificationCenterTabBreakingNews": { + "description": "Label for the 'Breaking News' tab in the notification center." + }, + "notificationCenterTabDigests": "Digests", + "@notificationCenterTabDigests": { + "description": "Label for the 'Digests' tab in the notification center." } } \ No newline at end of file diff --git a/lib/notifications/services/firebase_push_notification_service.dart b/lib/notifications/services/firebase_push_notification_service.dart index c18fa3fc..5065909d 100644 --- a/lib/notifications/services/firebase_push_notification_service.dart +++ b/lib/notifications/services/firebase_push_notification_service.dart @@ -22,10 +22,11 @@ class FirebasePushNotificationService implements PushNotificationService { _pushNotificationDeviceRepository; final Logger _logger; - final _onMessageController = StreamController(); + final _onMessageController = + StreamController.broadcast(); final _onMessageOpenedAppController = - StreamController(); - final _onTokenRefreshedController = StreamController(); + StreamController.broadcast(); + final _onTokenRefreshedController = StreamController.broadcast(); @override Stream get onMessage => _onMessageController.stream; @@ -74,11 +75,10 @@ class FirebasePushNotificationService implements PushNotificationService { '${message.toMap()}', ); final payload = _toPushNotificationPayload(message); - if (isOpenedApp) { - _onMessageOpenedAppController.add(payload); - } else { - _onMessageController.add(payload); - } + + (isOpenedApp ? _onMessageOpenedAppController : _onMessageController).add( + payload, + ); } @override @@ -106,6 +106,7 @@ class FirebasePushNotificationService implements PushNotificationService { @override Future registerDevice({required String userId}) async { _logger.info('Registering device for user: $userId'); + try { final token = await FirebaseMessaging.instance.getToken(); if (token == null) { diff --git a/lib/notifications/services/no_op_push_notification_service.dart b/lib/notifications/services/no_op_push_notification_service.dart index 7cf5bb62..f553e449 100644 --- a/lib/notifications/services/no_op_push_notification_service.dart +++ b/lib/notifications/services/no_op_push_notification_service.dart @@ -1,5 +1,9 @@ +import 'dart:async'; + import 'package:core/core.dart'; +import 'package:data_repository/data_repository.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/config/app_environment.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/notifications/services/push_notification_service.dart'; /// {@template no_op_push_notification_service} @@ -11,7 +15,25 @@ import 'package:flutter_news_app_mobile_client_full_source_code/notifications/se /// empty implementations for all required methods. /// {@endtemplate} class NoOpPushNotificationService extends PushNotificationService { + /// Creates an instance of [NoOpPushNotificationService]. + NoOpPushNotificationService({ + required DataRepository inAppNotificationRepository, + required List inAppNotificationsFixturesData, + required this.environment, + }) : _inAppNotificationRepository = inAppNotificationRepository, + _inAppNotificationsFixturesData = inAppNotificationsFixturesData; final ValueNotifier _permissionState = ValueNotifier(false); + final _onMessageController = + StreamController.broadcast(); + + final DataRepository _inAppNotificationRepository; + final List _inAppNotificationsFixturesData; + + /// The current application environment. + final AppEnvironment environment; + + @override + Stream get onMessage => _onMessageController.stream; @override Future initialize() async {} @@ -33,10 +55,36 @@ class NoOpPushNotificationService extends PushNotificationService { } @override - Future registerDevice({required String userId}) async {} + Future registerDevice({required String userId}) async { + // In demo mode, simulate receiving a push notification a few seconds + // after the user "registers" their device (i.e., logs in). + if (environment != AppEnvironment.demo) { + return; + } + unawaited( + Future.delayed(const Duration(seconds: 10), () async { + if (_inAppNotificationsFixturesData.isEmpty) return; + + // Use the first notification from the fixtures as the simulated push. + final notificationToSimulate = _inAppNotificationsFixturesData.first; + + // To align with the backend architecture, this NoOp service + // simulates the backend's behavior: it first persists the notification + // to the in-memory repository, and *then* emits the push payload. + await _inAppNotificationRepository.create( + item: notificationToSimulate, + userId: userId, + ); + + _onMessageController.add(notificationToSimulate.payload); + }), + ); + } @override - Stream get onMessage => const Stream.empty(); + Future close() async { + await _onMessageController.close(); + } @override Stream get onMessageOpenedApp => @@ -49,8 +97,9 @@ class NoOpPushNotificationService extends PushNotificationService { Future get initialMessage async => null; @override - Future close() async {} - - @override - List get props => []; + List get props => [ + _inAppNotificationRepository, + _inAppNotificationsFixturesData, + environment, + ]; } diff --git a/lib/notifications/services/one_signal_push_notification_service.dart b/lib/notifications/services/one_signal_push_notification_service.dart index 452cc277..27b944c6 100644 --- a/lib/notifications/services/one_signal_push_notification_service.dart +++ b/lib/notifications/services/one_signal_push_notification_service.dart @@ -24,10 +24,11 @@ class OneSignalPushNotificationService extends PushNotificationService { _pushNotificationDeviceRepository; final Logger _logger; - final _onMessageController = StreamController(); + final _onMessageController = + StreamController.broadcast(); final _onMessageOpenedAppController = - StreamController(); - final _onTokenRefreshedController = StreamController(); + StreamController.broadcast(); + final _onTokenRefreshedController = StreamController.broadcast(); // OneSignal doesn't have a direct equivalent of `getInitialMessage`. // We rely on the `setNotificationOpenedHandler`. @@ -47,7 +48,7 @@ class OneSignalPushNotificationService extends PushNotificationService { @override Future initialize() async { _logger.info('Initializing OneSignalPushNotificationService...'); - OneSignal.Debug.setLogLevel(OSLogLevel.verbose); + await OneSignal.Debug.setLogLevel(OSLogLevel.verbose); OneSignal.initialize(_appId); // Listen for changes to the push subscription state. If the token (player @@ -70,7 +71,7 @@ class OneSignalPushNotificationService extends PushNotificationService { // Prevent OneSignal from displaying the notification automatically. event.preventDefault(); // We handle it by adding to our stream. - _onMessageController.add(_toPushNotificationPayload(event.notification)); + _handleMessage(event.notification, isOpenedApp: false); }); // Handles notifications that are tapped by the user. @@ -78,9 +79,7 @@ class OneSignalPushNotificationService extends PushNotificationService { _logger.fine( 'OneSignal notification clicked: ${event.notification.jsonRepresentation()}', ); - _onMessageOpenedAppController.add( - _toPushNotificationPayload(event.notification), - ); + _handleMessage(event.notification, isOpenedApp: true); }); _logger.info('OneSignalPushNotificationService initialized.'); @@ -102,6 +101,7 @@ class OneSignalPushNotificationService extends PushNotificationService { @override Future registerDevice({required String userId}) async { _logger.info('Registering device for user: $userId'); + try { // OneSignal automatically handles token retrieval and storage. // We just need to get the OneSignal Player ID (push subscription ID). @@ -155,6 +155,17 @@ class OneSignalPushNotificationService extends PushNotificationService { } } + void _handleMessage( + OSNotification notification, { + required bool isOpenedApp, + }) { + final payload = _toPushNotificationPayload(notification); + + (isOpenedApp ? _onMessageOpenedAppController : _onMessageController).add( + payload, + ); + } + /// Converts a OneSignal [OSNotification] to a generic [PushNotificationPayload]. PushNotificationPayload _toPushNotificationPayload( OSNotification osNotification, diff --git a/lib/router/router.dart b/lib/router/router.dart index a620a7e4..a8ef3b20 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -3,6 +3,7 @@ import 'package:core/core.dart' hide AppStatus; import 'package:data_repository/data_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/account/bloc/in_app_notification_center_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/view/account_page.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/view/followed_contents/countries/add_country_to_follow_page.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/view/followed_contents/countries/followed_countries_list_page.dart'; @@ -11,6 +12,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/account/view/fol import 'package:flutter_news_app_mobile_client_full_source_code/account/view/followed_contents/sources/followed_sources_list_page.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/view/followed_contents/topics/add_topic_to_follow_page.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/view/followed_contents/topics/followed_topics_list_page.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/account/view/in_app_notification_center_page.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/account/view/saved_headlines_page.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/models/ad_theme_style.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/ads/services/ad_service.dart'; @@ -231,6 +233,23 @@ GoRouter createRouter({ pageBuilder: (context, state) => const MaterialPage(fullscreenDialog: true, child: AccountPage()), routes: [ + GoRoute( + path: Routes.notificationsCenter, + name: Routes.notificationsCenterName, + builder: (context, state) { + // Provide the InAppNotificationCenterBloc here so it's available + // in the BuildContext when InAppNotificationCenterPage's initState runs. + return BlocProvider( + create: (context) => InAppNotificationCenterBloc( + inAppNotificationRepository: context + .read>(), + appBloc: context.read(), + logger: context.read(), + )..add(const InAppNotificationCenterSubscriptionRequested()), + child: const InAppNotificationCenterPage(), + ); + }, + ), // The settings section within the account modal. It uses a // ShellRoute to provide a SettingsBloc to all its children. ShellRoute( @@ -353,8 +372,12 @@ GoRouter createRouter({ path: Routes.accountArticleDetails, name: Routes.accountArticleDetailsName, builder: (context, state) { - final headlineFromExtra = state.extra as Headline?; + final extra = state.extra; + final headlineFromExtra = extra is Headline ? extra : null; final headlineIdFromPath = state.pathParameters['id']; + final notificationId = extra is Map + ? extra['notificationId'] as String? + : null; return MultiBlocProvider( providers: [ BlocProvider( @@ -373,6 +396,7 @@ GoRouter createRouter({ child: HeadlineDetailsPage( initialHeadline: headlineFromExtra, headlineId: headlineFromExtra?.id ?? headlineIdFromPath, + notificationId: notificationId, ), ); }, @@ -440,8 +464,14 @@ GoRouter createRouter({ path: Routes.globalArticleDetails, name: Routes.globalArticleDetailsName, builder: (context, state) { - final headlineFromExtra = state.extra as Headline?; + // The 'extra' can be a Headline object (from feed navigation) or a Map + // (from a push notification deep-link). + final extra = state.extra; + final headlineFromExtra = extra is Headline ? extra : null; final headlineIdFromPath = state.pathParameters['id']; + final notificationId = extra is Map + ? extra['notificationId'] as String? + : null; return MultiBlocProvider( providers: [ @@ -459,6 +489,7 @@ GoRouter createRouter({ child: HeadlineDetailsPage( initialHeadline: headlineFromExtra, headlineId: headlineFromExtra?.id ?? headlineIdFromPath, + notificationId: notificationId, ), ); }, @@ -533,8 +564,14 @@ GoRouter createRouter({ path: 'article/:id', name: Routes.articleDetailsName, builder: (context, state) { - final headlineFromExtra = state.extra as Headline?; + final extra = state.extra; + final headlineFromExtra = extra is Headline + ? extra + : null; final headlineIdFromPath = state.pathParameters['id']; + final notificationId = extra is Map + ? extra['notificationId'] as String? + : null; return MultiBlocProvider( providers: [ @@ -555,20 +592,11 @@ GoRouter createRouter({ initialHeadline: headlineFromExtra, headlineId: headlineFromExtra?.id ?? headlineIdFromPath, + notificationId: notificationId, ), ); }, ), - // Sub-route for notifications page. - GoRoute( - path: Routes.notifications, - name: Routes.notificationsName, - builder: (context, state) { - return const Placeholder( - child: Center(child: Text('NOTIFICATIONS PAGE')), - ); - }, - ), GoRoute( path: Routes.savedHeadlineFilters, name: Routes.savedHeadlineFiltersName, diff --git a/lib/router/routes.dart b/lib/router/routes.dart index 4277c6fc..3730950b 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -35,6 +35,8 @@ abstract final class Routes { static const settingsName = 'settings'; static const manageFollowedItems = 'manage-followed-items'; static const manageFollowedItemsName = 'manageFollowedItems'; + static const notificationsCenter = 'notifications-center'; + static const notificationsCenterName = 'notificationsCenter'; // --- Relative Sub-Routes --- // These routes are defined with relative paths and are intended to be @@ -45,8 +47,6 @@ abstract final class Routes { // Feed static const articleDetailsName = 'articleDetails'; - static const notifications = 'notifications'; - static const notificationsName = 'notifications'; static const feedFilter = 'filter'; static const feedFilterName = 'feedFilter'; static const feedFilterTopics = 'topics'; diff --git a/lib/shared/widgets/notification_indicator.dart b/lib/shared/widgets/notification_indicator.dart index 7d2dda84..fd430e40 100644 --- a/lib/shared/widgets/notification_indicator.dart +++ b/lib/shared/widgets/notification_indicator.dart @@ -34,7 +34,7 @@ class NotificationIndicator extends StatelessWidget { width: AppSpacing.sm, height: AppSpacing.sm, decoration: BoxDecoration( - color: Theme.of(context).colorScheme.error, + color: Theme.of(context).colorScheme.primary, shape: BoxShape.circle, border: Border.all( color: Theme.of(context).colorScheme.surface, diff --git a/pubspec.lock b/pubspec.lock index aba946f9..34844ee3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -185,8 +185,8 @@ packages: dependency: "direct main" description: path: "." - ref: f409be2590785b4f35e260b0e15a65e5bdfe9ee0 - resolved-ref: f409be2590785b4f35e260b0e15a65e5bdfe9ee0 + ref: b01f9b457d4faaa6fab58f67ebc09d93314e6b10 + resolved-ref: b01f9b457d4faaa6fab58f67ebc09d93314e6b10 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 2e9c5f08..a00c8646 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: f409be2590785b4f35e260b0e15a65e5bdfe9ee0 + ref: b01f9b457d4faaa6fab58f67ebc09d93314e6b10 http_client: git: url: https://github.com/flutter-news-app-full-source-code/http-client.git