From cf0ef5c3b5d813bcfb46b004c54c1de5194d8174 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 19:31:55 +0100 Subject: [PATCH 01/41] feat(l10n): add notification center translations - Add Arabic and English translations for the in-app notification center - Include strings for page title, buttons, loading states, failure states, empty states, and tab labels - Update existing notification-related translations --- lib/l10n/app_localizations.dart | 66 ++++++++++++++++++++++++++++++ lib/l10n/app_localizations_ar.dart | 35 ++++++++++++++++ lib/l10n/app_localizations_en.dart | 36 ++++++++++++++++ lib/l10n/arb/app_ar.arb | 44 ++++++++++++++++++++ lib/l10n/arb/app_en.arb | 47 ++++++++++++++++++++- 5 files changed, 226 insertions(+), 2 deletions(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index cee81838..b96d8660 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2461,6 +2461,72 @@ 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 'All' tab in the notification center. + /// + /// In en, this message translates to: + /// **'All'** + String get notificationCenterTabAll; + + /// 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..82933fb3 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -1294,4 +1294,39 @@ 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 notificationCenterTabAll => 'الكل'; + + @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..67382ff4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1297,4 +1297,40 @@ 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 notificationCenterTabAll => 'All'; + + @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..48e54f28 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1681,5 +1681,49 @@ "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." + }, + "notificationCenterTabAll": "الكل", + "@notificationCenterTabAll": { + "description": "Label for the 'All' tab in the notification center." + }, + "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..86510621 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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,49 @@ "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." + }, + "notificationCenterTabAll": "All", + "@notificationCenterTabAll": { + "description": "Label for the 'All' tab in the notification center." + }, + "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 From 38ddef7ac9f65b226975db8ab30d033cb2471f19 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 19:32:06 +0100 Subject: [PATCH 02/41] feat(push-notification): add in-app notification support - Implement in-app notification client and repository - Integrate in-app notification support with push notification service - Update bootstrap process to include in-app notification initialization --- lib/bootstrap.dart | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index bb262fd2..4f5c60f1 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,12 @@ Future bootstrap( getId: (i) => i.id, logger: logger, ); + inAppNotificationClient = DataInMemory( + toJson: (i) => i.toJson(), + getId: (i) => i.id, + initialData: inAppNotificationsFixturesData, + logger: logger, + ); pushNotificationDeviceClient = DataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, @@ -341,6 +348,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 +414,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 +447,9 @@ Future bootstrap( dataClient: userAppSettingsClient, ); final userRepository = DataRepository(dataClient: userClient); + final inAppNotificationRepository = DataRepository( + dataClient: inAppNotificationClient, + ); final pushNotificationDeviceRepository = DataRepository( dataClient: pushNotificationDeviceClient, @@ -455,6 +479,7 @@ Future bootstrap( logger.fine('Using FirebasePushNotificationService.'); pushNotificationService = FirebasePushNotificationService( pushNotificationDeviceRepository: pushNotificationDeviceRepository, + inAppNotificationRepository: inAppNotificationRepository, logger: logger, ); case PushNotificationProvider.oneSignal: @@ -462,6 +487,7 @@ Future bootstrap( pushNotificationService = OneSignalPushNotificationService( appId: appConfig.oneSignalAppId, pushNotificationDeviceRepository: pushNotificationDeviceRepository, + inAppNotificationRepository: inAppNotificationRepository, logger: logger, ); } @@ -548,6 +574,7 @@ Future bootstrap( userAppSettingsRepository: userAppSettingsRepository, userContentPreferencesRepository: userContentPreferencesRepository, pushNotificationService: pushNotificationService, + inAppNotificationRepository: inAppNotificationRepository, environment: environment, adService: adService, feedDecoratorService: feedDecoratorService, From 83a57d0ccc39e2b7dbea068c80442700793b27cd Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 19:32:19 +0100 Subject: [PATCH 03/41] feat(app): add in-app notification repository to app initialization - Add inAppNotificationRepository to AppInitializationPage constructor - Pass inAppNotificationRepository to app life cycle status pages - Update formatting and indentation in the build method --- lib/app/view/app_initialization_page.dart | 29 +++++++++++++---------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/lib/app/view/app_initialization_page.dart b/lib/app/view/app_initialization_page.dart index deec5e88..69c625b2 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, @@ -147,25 +150,25 @@ class AppInitializationPage extends StatelessWidget { AppLifeCycleStatus.underMaintenance => const MaintenancePage(), AppLifeCycleStatus.updateRequired => UpdateRequiredPage( - currentAppVersion: failureData.currentAppVersion, - latestRequiredVersion: failureData.latestAppVersion, - ), + currentAppVersion: failureData.currentAppVersion, + latestRequiredVersion: failureData.latestAppVersion, + ), AppLifeCycleStatus.criticalError => CriticalErrorPage( - exception: failureData.error, - onRetry: () { - // For a critical error, we trigger a full app restart - // to ensure a clean state. - AppHotRestartWrapper.restartApp(context); - }, - ), + exception: failureData.error, + onRetry: () { + // For a critical error, we trigger a full app restart + // to ensure a clean state. + AppHotRestartWrapper.restartApp(context); + }, + ), // The other AppLifeCycleStatus values are not possible failure // states from the initializer, so we default to a critical // error page as a safe fallback. // ignore: no_default_cases _ => CriticalErrorPage( - exception: failureData.error, - onRetry: () => AppHotRestartWrapper.restartApp(context), - ), + exception: failureData.error, + onRetry: () => AppHotRestartWrapper.restartApp(context), + ), }, ); From 558794a0923c5f304e3ef7af1574e255987ced9f Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 19:32:45 +0100 Subject: [PATCH 04/41] feat(app): handle in-app notification read events - Add handlers for AppInAppNotificationMarkedAsRead and AppAllInAppNotificationsMarkedAsRead events - Update AppBloc to listen for in-app notification received events - Implement logic to update unread notification status - Add new AppInAppNotificationMarkedAsRead and AppAllInAppNotificationsMarkedAsRead events --- lib/app/bloc/app_bloc.dart | 51 ++++++++++++++++++++++++++++++++++++- lib/app/bloc/app_event.dart | 18 +++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 1f07549c..73c8c826 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,10 @@ class AppBloc extends Bloc { _pushNotificationService.onTokenRefreshed.listen((_) { add(const AppPushNotificationTokenRefreshed()); }); + + _pushNotificationService.onInAppNotificationReceived.listen((_) { + add(const AppInAppNotificationReceived()); + }); } final Logger _logger; @@ -105,6 +115,7 @@ class AppBloc extends Bloc { final DataRepository _userContentPreferencesRepository; final DataRepository _userRepository; + final DataRepository _inAppNotificationRepository; final PushNotificationService _pushNotificationService; final InlineAdCacheService _inlineAdCacheService; @@ -625,6 +636,44 @@ 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(); +} From 7bdb2d79c7409cd223b48d5f9a175c77a006d0d5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 19:32:54 +0100 Subject: [PATCH 05/41] feat(app): inject in-app notification repository into App widget - Add InAppNotification repository as a required parameter in App constructor - Store inAppNotificationRepository as an instance variable - Provide inAppNotificationRepository using RepositoryProvider - Pass inAppNotificationRepository to AppBloc initialization --- lib/app/view/app.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 187104b9..5e03dfe1 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -52,6 +52,8 @@ class App extends StatelessWidget { required DataRepository userContentPreferencesRepository, required AppEnvironment environment, + required DataRepository + inAppNotificationRepository, required InlineAdCacheService inlineAdCacheService, required AdService adService, required FeedDecoratorService feedDecoratorService, @@ -69,6 +71,7 @@ class App extends StatelessWidget { _userAppSettingsRepository = userAppSettingsRepository, _userContentPreferencesRepository = userContentPreferencesRepository, _pushNotificationService = pushNotificationService, + _inAppNotificationRepository = inAppNotificationRepository, _environment = environment, _adService = adService, _feedDecoratorService = feedDecoratorService, @@ -99,6 +102,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 +129,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 +153,7 @@ class App extends StatelessWidget { _userContentPreferencesRepository, logger: context.read(), pushNotificationService: _pushNotificationService, + inAppNotificationRepository: _inAppNotificationRepository, userRepository: _userRepository, inlineAdCacheService: _inlineAdCacheService, )..add(const AppStarted()), From 292fc03d9e1bd03386d64a797575a1cb4f7bcec2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 19:33:06 +0100 Subject: [PATCH 06/41] feat(router): add in-app notification center route - Add new route for in-app notification center in router.dart - Define new route constants for notifications in routes.dart - Import in-app notification center page in router.dart --- lib/router/router.dart | 7 +++++++ lib/router/routes.dart | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index a620a7e4..2dc33e08 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -45,6 +45,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/v import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/view/source_list_filter_page.dart' as feed_filter; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/view/topic_filter_page.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/in_app_notification_center/view/in_app_notification_center_page.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/go_router_observer.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; @@ -220,6 +221,12 @@ GoRouter createRouter({ ), ], ), + GoRoute( + path: Routes.notifications, + name: Routes.notificationsName, + builder: (context, state) => + const InAppNotificationCenterPage(), + ), ], ), diff --git a/lib/router/routes.dart b/lib/router/routes.dart index 4277c6fc..8dad9d84 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 notifications = 'notifications'; + static const notificationsName = 'notifications'; // --- 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'; From 1d21317273158dd1dde80d47cdc53a497ef3a2ab Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 19:33:41 +0100 Subject: [PATCH 07/41] feat(account): add notifications tile with indicator - Add a new ListTile for notifications in the account page - Implement a BlocSelector to check for unread notifications - Create a helper function to build ListTiles with optional indicators - Update imports to include the shared widgets library --- lib/account/view/account_page.dart | 61 +++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/lib/account/view/account_page.dart b/lib/account/view/account_page.dart index b5aaa909..8fdea1ee 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( + context: context, icon: Icons.tune_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.notificationsName); + }, + // 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, + ); + } } From 032a842ef03e94423ed19bb8aa576a73ce210d9b Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 19:34:06 +0100 Subject: [PATCH 08/41] feat(in-app-notification): implement BLoC for notification center - Add InAppNotificationCenterBloc to manage notification center state - Implement events for subscription, mark as read, and tab changes - Define state with status, notifications, and error handling - Add logic to fetch, filter, and update notifications - Integrate with AppBloc for global unread status management --- .../bloc/in_app_notification_center_bloc.dart | 258 ++++++++++++++++++ .../in_app_notification_center_event.dart | 44 +++ .../in_app_notification_center_state.dart | 90 ++++++ 3 files changed, 392 insertions(+) create mode 100644 lib/in_app_notification_center/bloc/in_app_notification_center_bloc.dart create mode 100644 lib/in_app_notification_center/bloc/in_app_notification_center_event.dart create mode 100644 lib/in_app_notification_center/bloc/in_app_notification_center_state.dart diff --git a/lib/in_app_notification_center/bloc/in_app_notification_center_bloc.dart b/lib/in_app_notification_center/bloc/in_app_notification_center_bloc.dart new file mode 100644 index 00000000..7fa6ed30 --- /dev/null +++ b/lib/in_app_notification_center/bloc/in_app_notification_center_bloc.dart @@ -0,0 +1,258 @@ +import 'dart:async'; + +import 'package:bloc/bloc.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); + } + + 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; + + // Filter notifications into their respective categories based on the + // deliveryType specified in the payload's data map. + final breakingNews = allNotifications + .where( + (n) => + n.payload.data['deliveryType'] == + PushNotificationSubscriptionDeliveryType.breakingOnly.name, + ) + .toList(); + + final digests = allNotifications.where((n) { + final deliveryType = n.payload.data['deliveryType'] as String?; + return deliveryType == + PushNotificationSubscriptionDeliveryType.dailyDigest.name || + deliveryType == + PushNotificationSubscriptionDeliveryType.weeklyRoundup.name; + }).toList(); + + emit( + state.copyWith( + status: InAppNotificationCenterStatus.success, + notifications: allNotifications, + 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.firstWhere( + (n) => n.id == event.notificationId, + orElse: () => throw Exception('Notification not found in state'), + ); + + // If already read, do nothing. + if (notification.isRead) 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 updatedList = state.notifications + .map((n) => n.id == notification.id ? updatedNotification : n) + .toList(); + + 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( + notifications: updatedList, + 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 fullyUpdatedList = state.notifications + .map((n) => n.isRead ? n : n.copyWith(readAt: now)) + .toList(); + + 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( + notifications: fullyUpdatedList, + 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/in_app_notification_center/bloc/in_app_notification_center_event.dart b/lib/in_app_notification_center/bloc/in_app_notification_center_event.dart new file mode 100644 index 00000000..e8f046f8 --- /dev/null +++ b/lib/in_app_notification_center/bloc/in_app_notification_center_event.dart @@ -0,0 +1,44 @@ +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. + final int tabIndex; + + @override + List get props => [tabIndex]; +} diff --git a/lib/in_app_notification_center/bloc/in_app_notification_center_state.dart b/lib/in_app_notification_center/bloc/in_app_notification_center_state.dart new file mode 100644 index 00000000..fa99fe76 --- /dev/null +++ b/lib/in_app_notification_center/bloc/in_app_notification_center_state.dart @@ -0,0 +1,90 @@ +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.currentTabIndex = 0, + this.notifications = const [], + this.breakingNewsNotifications = const [], + this.error, + this.digestNotifications = const [], + }); + + /// The currently selected tab index. + /// 0: All, 1: Breaking News, 2: 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; + + /// Returns the list of notifications filtered by the current tab. + List get filteredNotifications { + return switch (currentTabIndex) { + 1 => breakingNewsNotifications, + 2 => digestNotifications, + _ => notifications, // Default to 'All' tab + }; + } + + /// The current status of the notification center. + final InAppNotificationCenterStatus status; + + /// The list of notifications. + final List notifications; + + /// An error that occurred during notification loading or processing. + final HttpException? error; + + @override + List get props => [ + status, + notifications, + 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, + List? notifications, + HttpException? error, + int? currentTabIndex, + List? breakingNewsNotifications, + List? digestNotifications, + }) { + return InAppNotificationCenterState( + status: status ?? this.status, + notifications: notifications ?? this.notifications, + error: error ?? this.error, + currentTabIndex: currentTabIndex ?? this.currentTabIndex, + breakingNewsNotifications: + breakingNewsNotifications ?? this.breakingNewsNotifications, + digestNotifications: digestNotifications ?? this.digestNotifications, + ); + } +} From 3fb38e99ccbd912f2965a7fce896810fb36794fb Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 19:34:27 +0100 Subject: [PATCH 09/41] feat(in-app notification center): add notification center page and list item widget - Implement InAppNotificationCenterPage to display a chronological list of in-app notifications - Add InAppNotificationListItem widget to show individual notifications in a list - Include functionality to mark notifications as read and navigate to related content - Add tabs for different notification categories: All, Breaking News, and Digests - Implement loading, failure, and empty states for the notification list --- .../view/in_app_notification_center_page.dart | 206 ++++++++++++++++++ .../in_app_notification_list_item.dart | 72 ++++++ 2 files changed, 278 insertions(+) create mode 100644 lib/in_app_notification_center/view/in_app_notification_center_page.dart create mode 100644 lib/in_app_notification_center/widgets/in_app_notification_list_item.dart diff --git a/lib/in_app_notification_center/view/in_app_notification_center_page.dart b/lib/in_app_notification_center/view/in_app_notification_center_page.dart new file mode 100644 index 00000000..79cb6826 --- /dev/null +++ b/lib/in_app_notification_center/view/in_app_notification_center_page.dart @@ -0,0 +1,206 @@ +import 'package:core/core.dart'; +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/ads/services/interstitial_ad_manager.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/in_app_notification_center/bloc/in_app_notification_center_bloc.dart'; +import 'package:flutter_news_app_mobile_client_full_source_code/in_app_notification_center/widgets/in_app_notification_list_item.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:logging/logging.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: 3, 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 BlocProvider( + create: (context) => InAppNotificationCenterBloc( + inAppNotificationRepository: context + .read>(), + appBloc: context.read(), + logger: context.read(), + )..add(const InAppNotificationCenterSubscriptionRequested()), + child: Scaffold( + appBar: AppBar( + title: Text(l10n.notificationCenterPageTitle), + actions: [ + BlocBuilder< + InAppNotificationCenterBloc, + InAppNotificationCenterState + >( + builder: (context, state) { + final hasUnread = state.notifications.any((n) => !n.isRead); + return TextButton( + onPressed: hasUnread + ? () { + context.read().add( + const InAppNotificationCenterMarkAllAsRead(), + ); + } + : null, + child: Text(l10n.notificationCenterMarkAllAsReadButton), + ); + }, + ), + ], + bottom: TabBar( + controller: _tabController, + tabs: [ + Tab(text: l10n.notificationCenterTabAll), + 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.notifications), + _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/in_app_notification_center/widgets/in_app_notification_list_item.dart b/lib/in_app_notification_center/widgets/in_app_notification_list_item.dart new file mode 100644 index 00000000..a5fcabd8 --- /dev/null +++ b/lib/in_app_notification_center/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, + ); + } +} From 80768778d5d8ccc9d90f4e875a2101fe15f182e3 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 19:34:51 +0100 Subject: [PATCH 10/41] feat(notifications): add onInAppNotificationReceived stream to PushNotificationService - Introduce new abstract method for handling in-app notifications - Update documentation for existing methods --- lib/notifications/services/push_notification_service.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/notifications/services/push_notification_service.dart b/lib/notifications/services/push_notification_service.dart index e39af6af..50101d75 100644 --- a/lib/notifications/services/push_notification_service.dart +++ b/lib/notifications/services/push_notification_service.dart @@ -52,6 +52,12 @@ abstract class PushNotificationService extends Equatable { /// This is used by the AppBloc to trigger device re-registration. Stream get onTokenRefreshed; + /// A stream of in-app notifications that have been received and persisted. + /// + /// This stream is used by the AppBloc to update the global unread indicator + /// and by the Notification Center to display new notifications. + Stream get onInAppNotificationReceived; + /// Gets the initial notification that caused the app to open from a /// terminated state. /// From 434c692bed6569f4930c903c7f009abb2c245a8f Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 19:35:07 +0100 Subject: [PATCH 11/41] feat(notifications): persist push notifications as in-app notifications - Add InAppNotification repository dependency - Implement in-app notification persistence logic - Add onInAppNotificationReceived stream - Update registerDevice method to store current user --- .../firebase_push_notification_service.dart | 58 +++++++++++++++++-- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/lib/notifications/services/firebase_push_notification_service.dart b/lib/notifications/services/firebase_push_notification_service.dart index c18fa3fc..3926df1b 100644 --- a/lib/notifications/services/firebase_push_notification_service.dart +++ b/lib/notifications/services/firebase_push_notification_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; @@ -14,18 +15,28 @@ class FirebasePushNotificationService implements PushNotificationService { FirebasePushNotificationService({ required DataRepository pushNotificationDeviceRepository, + required DataRepository inAppNotificationRepository, required Logger logger, }) : _pushNotificationDeviceRepository = pushNotificationDeviceRepository, + _inAppNotificationRepository = inAppNotificationRepository, _logger = logger; final DataRepository _pushNotificationDeviceRepository; + final DataRepository _inAppNotificationRepository; final Logger _logger; - final _onMessageController = StreamController(); + final _onMessageController = + StreamController.broadcast(); final _onMessageOpenedAppController = - StreamController(); - final _onTokenRefreshedController = StreamController(); + StreamController.broadcast(); + final _onTokenRefreshedController = StreamController.broadcast(); + final _onInAppNotificationReceivedController = + StreamController.broadcast(); + + // Store the userId from the last successful registration. + // This is used to associate incoming notifications with the correct user. + String? _currentUserId; @override Stream get onMessage => _onMessageController.stream; @@ -37,6 +48,10 @@ class FirebasePushNotificationService implements PushNotificationService { @override Stream get onTokenRefreshed => _onTokenRefreshedController.stream; + @override + Stream get onInAppNotificationReceived => + _onInAppNotificationReceivedController.stream; + @override Future initialize() async { _logger.info('Initializing FirebasePushNotificationService...'); @@ -68,12 +83,37 @@ class FirebasePushNotificationService implements PushNotificationService { _logger.info('FirebasePushNotificationService initialized.'); } - void _handleMessage(RemoteMessage message, {required bool isOpenedApp}) { + Future _handleMessage( + RemoteMessage message, { + required bool isOpenedApp, + }) async { _logger.fine( 'Received Firebase message (isOpenedApp: $isOpenedApp): ' '${message.toMap()}', ); final payload = _toPushNotificationPayload(message); + + // Persist the notification if a user is logged in. + if (_currentUserId != null) { + try { + final newNotification = InAppNotification( + // Generate a random ID for the notification. + id: Random().nextInt(999999).toString(), + userId: _currentUserId!, + payload: payload, + createdAt: DateTime.now(), + ); + + final createdNotification = await _inAppNotificationRepository.create( + item: newNotification, + userId: _currentUserId, + ); + _onInAppNotificationReceivedController.add(createdNotification); + } catch (e, s) { + _logger.severe('Failed to persist in-app notification.', e, s); + } + } + if (isOpenedApp) { _onMessageOpenedAppController.add(payload); } else { @@ -106,6 +146,9 @@ class FirebasePushNotificationService implements PushNotificationService { @override Future registerDevice({required String userId}) async { _logger.info('Registering device for user: $userId'); + // Store the userId to be used for persisting incoming notifications. + _currentUserId = userId; + try { final token = await FirebaseMessaging.instance.getToken(); if (token == null) { @@ -173,10 +216,15 @@ class FirebasePushNotificationService implements PushNotificationService { await _onMessageController.close(); await _onMessageOpenedAppController.close(); await _onTokenRefreshedController.close(); + await _onInAppNotificationReceivedController.close(); } @override - List get props => [_pushNotificationDeviceRepository, _logger]; + List get props => [ + _pushNotificationDeviceRepository, + _inAppNotificationRepository, + _logger, + ]; @override bool? get stringify => true; From 609e81235f3564db6f8bc199b5ffacf2066c8541 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 19:35:26 +0100 Subject: [PATCH 12/41] feat(notifications): persist push notifications as in-app notifications - Add InAppNotification repository dependency - Implement in-app notification persistence logic - Add onInAppNotificationReceived stream - Update registerDevice method to store current user ID - Modify handleMessage to create and emit in-app notifications --- .../one_signal_push_notification_service.dart | 68 ++++++++++++++++--- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/lib/notifications/services/one_signal_push_notification_service.dart b/lib/notifications/services/one_signal_push_notification_service.dart index 452cc277..550ec998 100644 --- a/lib/notifications/services/one_signal_push_notification_service.dart +++ b/lib/notifications/services/one_signal_push_notification_service.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; @@ -14,20 +15,30 @@ class OneSignalPushNotificationService extends PushNotificationService { required String appId, required DataRepository pushNotificationDeviceRepository, + required DataRepository inAppNotificationRepository, required Logger logger, }) : _appId = appId, _pushNotificationDeviceRepository = pushNotificationDeviceRepository, + _inAppNotificationRepository = inAppNotificationRepository, _logger = logger; final String _appId; final DataRepository _pushNotificationDeviceRepository; + final DataRepository _inAppNotificationRepository; final Logger _logger; - final _onMessageController = StreamController(); + final _onMessageController = + StreamController.broadcast(); final _onMessageOpenedAppController = - StreamController(); - final _onTokenRefreshedController = StreamController(); + StreamController.broadcast(); + final _onTokenRefreshedController = StreamController.broadcast(); + final _onInAppNotificationReceivedController = + StreamController.broadcast(); + + // Store the userId from the last successful registration. + // This is used to associate incoming notifications with the correct user. + String? _currentUserId; // OneSignal doesn't have a direct equivalent of `getInitialMessage`. // We rely on the `setNotificationOpenedHandler`. @@ -44,6 +55,10 @@ class OneSignalPushNotificationService extends PushNotificationService { @override Stream get onTokenRefreshed => _onTokenRefreshedController.stream; + @override + Stream get onInAppNotificationReceived => + _onInAppNotificationReceivedController.stream; + @override Future initialize() async { _logger.info('Initializing OneSignalPushNotificationService...'); @@ -63,24 +78,22 @@ class OneSignalPushNotificationService extends PushNotificationService { }); // Handles notifications received while the app is in the foreground. - OneSignal.Notifications.addForegroundWillDisplayListener((event) { + OneSignal.Notifications.addForegroundWillDisplayListener((event) async { _logger.fine( 'OneSignal foreground message received: ${event.notification.jsonRepresentation()}', ); // Prevent OneSignal from displaying the notification automatically. event.preventDefault(); // We handle it by adding to our stream. - _onMessageController.add(_toPushNotificationPayload(event.notification)); + await _handleMessage(event.notification, isOpenedApp: false); }); // Handles notifications that are tapped by the user. - OneSignal.Notifications.addClickListener((event) { + OneSignal.Notifications.addClickListener((event) async { _logger.fine( 'OneSignal notification clicked: ${event.notification.jsonRepresentation()}', ); - _onMessageOpenedAppController.add( - _toPushNotificationPayload(event.notification), - ); + await _handleMessage(event.notification, isOpenedApp: true); }); _logger.info('OneSignalPushNotificationService initialized.'); @@ -102,6 +115,9 @@ class OneSignalPushNotificationService extends PushNotificationService { @override Future registerDevice({required String userId}) async { _logger.info('Registering device for user: $userId'); + // Store the userId to be used for persisting incoming notifications. + _currentUserId = userId; + try { // OneSignal automatically handles token retrieval and storage. // We just need to get the OneSignal Player ID (push subscription ID). @@ -155,6 +171,38 @@ class OneSignalPushNotificationService extends PushNotificationService { } } + Future _handleMessage( + OSNotification notification, { + required bool isOpenedApp, + }) async { + final payload = _toPushNotificationPayload(notification); + + // Persist the notification if a user is logged in. + if (_currentUserId != null) { + try { + final newNotification = InAppNotification( + // Generate a random ID for the notification. + id: Random().nextInt(999999).toString(), + userId: _currentUserId!, + payload: payload, + createdAt: DateTime.now(), + ); + + final createdNotification = await _inAppNotificationRepository.create( + item: newNotification, + userId: _currentUserId, + ); + _onInAppNotificationReceivedController.add(createdNotification); + } catch (e, s) { + _logger.severe('Failed to persist in-app notification.', e, s); + } + } + + (isOpenedApp ? _onMessageOpenedAppController : _onMessageController).add( + payload, + ); + } + /// Converts a OneSignal [OSNotification] to a generic [PushNotificationPayload]. PushNotificationPayload _toPushNotificationPayload( OSNotification osNotification, @@ -175,12 +223,14 @@ class OneSignalPushNotificationService extends PushNotificationService { await _onMessageController.close(); await _onMessageOpenedAppController.close(); await _onTokenRefreshedController.close(); + await _onInAppNotificationReceivedController.close(); } @override List get props => [ _appId, _pushNotificationDeviceRepository, + _inAppNotificationRepository, _logger, ]; } From 2648cd3f2d178412321ba73e29da41012cdb4457 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 19:35:35 +0100 Subject: [PATCH 13/41] feat(notifications): add in-app notification stream to no-op service - Import 'dart:async' for stream support - Add 'onInAppNotificationReceived' stream to NoOpPushNotificationService - This change ensures the no-op service implements the full PushNotificationService interface --- .../services/no_op_push_notification_service.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/notifications/services/no_op_push_notification_service.dart b/lib/notifications/services/no_op_push_notification_service.dart index 7cf5bb62..9f8979b4 100644 --- a/lib/notifications/services/no_op_push_notification_service.dart +++ b/lib/notifications/services/no_op_push_notification_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:core/core.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/notifications/services/push_notification_service.dart'; @@ -45,6 +46,10 @@ class NoOpPushNotificationService extends PushNotificationService { @override Stream get onTokenRefreshed => const Stream.empty(); + @override + Stream get onInAppNotificationReceived => + const Stream.empty(); + @override Future get initialMessage async => null; From a0777e503dc85317aeb8058f05fde9118f1c0f05 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 19:35:48 +0100 Subject: [PATCH 14/41] style: change notification indicator color to primary - Update the color of the notification indicator from error to primary - This change affects the appearance of the indicator in the UI --- lib/shared/widgets/notification_indicator.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 4b348fe993366ea9d1753efa5257a29b86179511 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 19:52:42 +0100 Subject: [PATCH 15/41] refactor(notifications): handle foreground push notifications in AppBloc - Move in-app notification handling logic from push notification services to AppBloc - Remove InAppNotification stream and related logic from PushNotificationService implementations - Update push notification services to only handle message routing - Simplify registration and device handling in push notification services --- lib/app/bloc/app_bloc.dart | 34 ++++++++++- .../firebase_push_notification_service.dart | 57 ++----------------- .../no_op_push_notification_service.dart | 4 -- .../one_signal_push_notification_service.dart | 53 +++-------------- .../services/push_notification_service.dart | 6 -- 5 files changed, 44 insertions(+), 110 deletions(-) diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 73c8c826..117819c7 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math'; import 'package:auth_repository/auth_repository.dart'; import 'package:core/core.dart'; @@ -102,8 +103,37 @@ class AppBloc extends Bloc { add(const AppPushNotificationTokenRefreshed()); }); - _pushNotificationService.onInAppNotificationReceived.listen((_) { - add(const AppInAppNotificationReceived()); + // Listen to raw foreground push notifications. + _pushNotificationService.onMessage.listen((payload) async { + _logger.fine('AppBloc received foreground push notification payload.'); + if (state.user != null) { + try { + final newNotification = InAppNotification( + // Generate a random ID for the notification. + // In a real-world scenario, this would likely be a UUID. + id: Random().nextInt(999999).toString(), + userId: state.user!.id, + payload: payload, + createdAt: DateTime.now(), + ); + + await _inAppNotificationRepository.create( + item: newNotification, + userId: state.user!.id, + ); + _logger.info( + 'Successfully persisted in-app notification for user ${state.user!.id}.', + ); + // Notify the app that a new notification has been received to update UI. + add(const AppInAppNotificationReceived()); + } catch (e, s) { + _logger.severe( + 'Failed to persist in-app notification from foreground message.', + e, + s, + ); + } + } }); } diff --git a/lib/notifications/services/firebase_push_notification_service.dart b/lib/notifications/services/firebase_push_notification_service.dart index 3926df1b..5065909d 100644 --- a/lib/notifications/services/firebase_push_notification_service.dart +++ b/lib/notifications/services/firebase_push_notification_service.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io'; -import 'dart:math'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; @@ -15,15 +14,12 @@ class FirebasePushNotificationService implements PushNotificationService { FirebasePushNotificationService({ required DataRepository pushNotificationDeviceRepository, - required DataRepository inAppNotificationRepository, required Logger logger, }) : _pushNotificationDeviceRepository = pushNotificationDeviceRepository, - _inAppNotificationRepository = inAppNotificationRepository, _logger = logger; final DataRepository _pushNotificationDeviceRepository; - final DataRepository _inAppNotificationRepository; final Logger _logger; final _onMessageController = @@ -31,12 +27,6 @@ class FirebasePushNotificationService implements PushNotificationService { final _onMessageOpenedAppController = StreamController.broadcast(); final _onTokenRefreshedController = StreamController.broadcast(); - final _onInAppNotificationReceivedController = - StreamController.broadcast(); - - // Store the userId from the last successful registration. - // This is used to associate incoming notifications with the correct user. - String? _currentUserId; @override Stream get onMessage => _onMessageController.stream; @@ -48,10 +38,6 @@ class FirebasePushNotificationService implements PushNotificationService { @override Stream get onTokenRefreshed => _onTokenRefreshedController.stream; - @override - Stream get onInAppNotificationReceived => - _onInAppNotificationReceivedController.stream; - @override Future initialize() async { _logger.info('Initializing FirebasePushNotificationService...'); @@ -83,42 +69,16 @@ class FirebasePushNotificationService implements PushNotificationService { _logger.info('FirebasePushNotificationService initialized.'); } - Future _handleMessage( - RemoteMessage message, { - required bool isOpenedApp, - }) async { + void _handleMessage(RemoteMessage message, {required bool isOpenedApp}) { _logger.fine( 'Received Firebase message (isOpenedApp: $isOpenedApp): ' '${message.toMap()}', ); final payload = _toPushNotificationPayload(message); - // Persist the notification if a user is logged in. - if (_currentUserId != null) { - try { - final newNotification = InAppNotification( - // Generate a random ID for the notification. - id: Random().nextInt(999999).toString(), - userId: _currentUserId!, - payload: payload, - createdAt: DateTime.now(), - ); - - final createdNotification = await _inAppNotificationRepository.create( - item: newNotification, - userId: _currentUserId, - ); - _onInAppNotificationReceivedController.add(createdNotification); - } catch (e, s) { - _logger.severe('Failed to persist in-app notification.', e, s); - } - } - - if (isOpenedApp) { - _onMessageOpenedAppController.add(payload); - } else { - _onMessageController.add(payload); - } + (isOpenedApp ? _onMessageOpenedAppController : _onMessageController).add( + payload, + ); } @override @@ -146,8 +106,6 @@ class FirebasePushNotificationService implements PushNotificationService { @override Future registerDevice({required String userId}) async { _logger.info('Registering device for user: $userId'); - // Store the userId to be used for persisting incoming notifications. - _currentUserId = userId; try { final token = await FirebaseMessaging.instance.getToken(); @@ -216,15 +174,10 @@ class FirebasePushNotificationService implements PushNotificationService { await _onMessageController.close(); await _onMessageOpenedAppController.close(); await _onTokenRefreshedController.close(); - await _onInAppNotificationReceivedController.close(); } @override - List get props => [ - _pushNotificationDeviceRepository, - _inAppNotificationRepository, - _logger, - ]; + List get props => [_pushNotificationDeviceRepository, _logger]; @override bool? get stringify => true; diff --git a/lib/notifications/services/no_op_push_notification_service.dart b/lib/notifications/services/no_op_push_notification_service.dart index 9f8979b4..065be263 100644 --- a/lib/notifications/services/no_op_push_notification_service.dart +++ b/lib/notifications/services/no_op_push_notification_service.dart @@ -46,10 +46,6 @@ class NoOpPushNotificationService extends PushNotificationService { @override Stream get onTokenRefreshed => const Stream.empty(); - @override - Stream get onInAppNotificationReceived => - const Stream.empty(); - @override Future get initialMessage async => null; diff --git a/lib/notifications/services/one_signal_push_notification_service.dart b/lib/notifications/services/one_signal_push_notification_service.dart index 550ec998..27b944c6 100644 --- a/lib/notifications/services/one_signal_push_notification_service.dart +++ b/lib/notifications/services/one_signal_push_notification_service.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io'; -import 'dart:math'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; @@ -15,17 +14,14 @@ class OneSignalPushNotificationService extends PushNotificationService { required String appId, required DataRepository pushNotificationDeviceRepository, - required DataRepository inAppNotificationRepository, required Logger logger, }) : _appId = appId, _pushNotificationDeviceRepository = pushNotificationDeviceRepository, - _inAppNotificationRepository = inAppNotificationRepository, _logger = logger; final String _appId; final DataRepository _pushNotificationDeviceRepository; - final DataRepository _inAppNotificationRepository; final Logger _logger; final _onMessageController = @@ -33,12 +29,6 @@ class OneSignalPushNotificationService extends PushNotificationService { final _onMessageOpenedAppController = StreamController.broadcast(); final _onTokenRefreshedController = StreamController.broadcast(); - final _onInAppNotificationReceivedController = - StreamController.broadcast(); - - // Store the userId from the last successful registration. - // This is used to associate incoming notifications with the correct user. - String? _currentUserId; // OneSignal doesn't have a direct equivalent of `getInitialMessage`. // We rely on the `setNotificationOpenedHandler`. @@ -55,14 +45,10 @@ class OneSignalPushNotificationService extends PushNotificationService { @override Stream get onTokenRefreshed => _onTokenRefreshedController.stream; - @override - Stream get onInAppNotificationReceived => - _onInAppNotificationReceivedController.stream; - @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 @@ -78,22 +64,22 @@ class OneSignalPushNotificationService extends PushNotificationService { }); // Handles notifications received while the app is in the foreground. - OneSignal.Notifications.addForegroundWillDisplayListener((event) async { + OneSignal.Notifications.addForegroundWillDisplayListener((event) { _logger.fine( 'OneSignal foreground message received: ${event.notification.jsonRepresentation()}', ); // Prevent OneSignal from displaying the notification automatically. event.preventDefault(); // We handle it by adding to our stream. - await _handleMessage(event.notification, isOpenedApp: false); + _handleMessage(event.notification, isOpenedApp: false); }); // Handles notifications that are tapped by the user. - OneSignal.Notifications.addClickListener((event) async { + OneSignal.Notifications.addClickListener((event) { _logger.fine( 'OneSignal notification clicked: ${event.notification.jsonRepresentation()}', ); - await _handleMessage(event.notification, isOpenedApp: true); + _handleMessage(event.notification, isOpenedApp: true); }); _logger.info('OneSignalPushNotificationService initialized.'); @@ -115,8 +101,6 @@ class OneSignalPushNotificationService extends PushNotificationService { @override Future registerDevice({required String userId}) async { _logger.info('Registering device for user: $userId'); - // Store the userId to be used for persisting incoming notifications. - _currentUserId = userId; try { // OneSignal automatically handles token retrieval and storage. @@ -171,33 +155,12 @@ class OneSignalPushNotificationService extends PushNotificationService { } } - Future _handleMessage( + void _handleMessage( OSNotification notification, { required bool isOpenedApp, - }) async { + }) { final payload = _toPushNotificationPayload(notification); - // Persist the notification if a user is logged in. - if (_currentUserId != null) { - try { - final newNotification = InAppNotification( - // Generate a random ID for the notification. - id: Random().nextInt(999999).toString(), - userId: _currentUserId!, - payload: payload, - createdAt: DateTime.now(), - ); - - final createdNotification = await _inAppNotificationRepository.create( - item: newNotification, - userId: _currentUserId, - ); - _onInAppNotificationReceivedController.add(createdNotification); - } catch (e, s) { - _logger.severe('Failed to persist in-app notification.', e, s); - } - } - (isOpenedApp ? _onMessageOpenedAppController : _onMessageController).add( payload, ); @@ -223,14 +186,12 @@ class OneSignalPushNotificationService extends PushNotificationService { await _onMessageController.close(); await _onMessageOpenedAppController.close(); await _onTokenRefreshedController.close(); - await _onInAppNotificationReceivedController.close(); } @override List get props => [ _appId, _pushNotificationDeviceRepository, - _inAppNotificationRepository, _logger, ]; } diff --git a/lib/notifications/services/push_notification_service.dart b/lib/notifications/services/push_notification_service.dart index 50101d75..e39af6af 100644 --- a/lib/notifications/services/push_notification_service.dart +++ b/lib/notifications/services/push_notification_service.dart @@ -52,12 +52,6 @@ abstract class PushNotificationService extends Equatable { /// This is used by the AppBloc to trigger device re-registration. Stream get onTokenRefreshed; - /// A stream of in-app notifications that have been received and persisted. - /// - /// This stream is used by the AppBloc to update the global unread indicator - /// and by the Notification Center to display new notifications. - Stream get onInAppNotificationReceived; - /// Gets the initial notification that caused the app to open from a /// terminated state. /// From 6537c3223b8776aa2308efdd2af65750ad2ad252 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 19:54:37 +0100 Subject: [PATCH 16/41] refactor(push_notification): remove in-app notification repository - Remove inAppNotificationRepository parameter from FirebasePushNotificationService and OneSignalPushNotificationService - This change simplifies the push notification service initialization process --- lib/bootstrap.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 4f5c60f1..3674c676 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -479,7 +479,6 @@ Future bootstrap( logger.fine('Using FirebasePushNotificationService.'); pushNotificationService = FirebasePushNotificationService( pushNotificationDeviceRepository: pushNotificationDeviceRepository, - inAppNotificationRepository: inAppNotificationRepository, logger: logger, ); case PushNotificationProvider.oneSignal: @@ -487,7 +486,6 @@ Future bootstrap( pushNotificationService = OneSignalPushNotificationService( appId: appConfig.oneSignalAppId, pushNotificationDeviceRepository: pushNotificationDeviceRepository, - inAppNotificationRepository: inAppNotificationRepository, logger: logger, ); } From b5277bae1278a8ebc7781f0da2708c9e3b7519f1 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 19:55:08 +0100 Subject: [PATCH 17/41] style: format --- lib/app/bloc/app_bloc.dart | 6 +++++- lib/app/view/app.dart | 3 +-- lib/app/view/app_initialization_page.dart | 26 +++++++++++------------ lib/router/router.dart | 3 +-- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 117819c7..18a085ed 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -699,7 +699,11 @@ class AppBloc extends Bloc { emit(state.copyWith(hasUnreadInAppNotifications: false)); } } catch (e, s) { - _logger.severe('Failed to check for remaining unread notifications.', e, s); + _logger.severe( + 'Failed to check for remaining unread notifications.', + e, + s, + ); // Do not change state on error to avoid inconsistent UI. } } diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 5e03dfe1..1b34dccd 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -52,8 +52,7 @@ class App extends StatelessWidget { required DataRepository userContentPreferencesRepository, required AppEnvironment environment, - required DataRepository - inAppNotificationRepository, + required DataRepository inAppNotificationRepository, required InlineAdCacheService inlineAdCacheService, required AdService adService, required FeedDecoratorService feedDecoratorService, diff --git a/lib/app/view/app_initialization_page.dart b/lib/app/view/app_initialization_page.dart index 69c625b2..267dc245 100644 --- a/lib/app/view/app_initialization_page.dart +++ b/lib/app/view/app_initialization_page.dart @@ -150,25 +150,25 @@ class AppInitializationPage extends StatelessWidget { AppLifeCycleStatus.underMaintenance => const MaintenancePage(), AppLifeCycleStatus.updateRequired => UpdateRequiredPage( - currentAppVersion: failureData.currentAppVersion, - latestRequiredVersion: failureData.latestAppVersion, - ), + currentAppVersion: failureData.currentAppVersion, + latestRequiredVersion: failureData.latestAppVersion, + ), AppLifeCycleStatus.criticalError => CriticalErrorPage( - exception: failureData.error, - onRetry: () { - // For a critical error, we trigger a full app restart - // to ensure a clean state. - AppHotRestartWrapper.restartApp(context); - }, - ), + exception: failureData.error, + onRetry: () { + // For a critical error, we trigger a full app restart + // to ensure a clean state. + AppHotRestartWrapper.restartApp(context); + }, + ), // The other AppLifeCycleStatus values are not possible failure // states from the initializer, so we default to a critical // error page as a safe fallback. // ignore: no_default_cases _ => CriticalErrorPage( - exception: failureData.error, - onRetry: () => AppHotRestartWrapper.restartApp(context), - ), + exception: failureData.error, + onRetry: () => AppHotRestartWrapper.restartApp(context), + ), }, ); diff --git a/lib/router/router.dart b/lib/router/router.dart index 2dc33e08..d68d5fff 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -224,8 +224,7 @@ GoRouter createRouter({ GoRoute( path: Routes.notifications, name: Routes.notificationsName, - builder: (context, state) => - const InAppNotificationCenterPage(), + builder: (context, state) => const InAppNotificationCenterPage(), ), ], ), From 6c289264a7444fbc97d2b6073de0365a611a2a30 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 20:01:42 +0100 Subject: [PATCH 18/41] chore: file relocate --- .../bloc/in_app_notification_center_bloc.dart | 0 .../bloc/in_app_notification_center_event.dart | 0 .../bloc/in_app_notification_center_state.dart | 0 .../view/in_app_notification_center_page.dart | 4 ++-- .../widgets/in_app_notification_list_item.dart | 0 lib/router/router.dart | 2 +- 6 files changed, 3 insertions(+), 3 deletions(-) rename lib/{in_app_notification_center => account}/bloc/in_app_notification_center_bloc.dart (100%) rename lib/{in_app_notification_center => account}/bloc/in_app_notification_center_event.dart (100%) rename lib/{in_app_notification_center => account}/bloc/in_app_notification_center_state.dart (100%) rename lib/{in_app_notification_center => account}/view/in_app_notification_center_page.dart (98%) rename lib/{in_app_notification_center => account}/widgets/in_app_notification_list_item.dart (100%) diff --git a/lib/in_app_notification_center/bloc/in_app_notification_center_bloc.dart b/lib/account/bloc/in_app_notification_center_bloc.dart similarity index 100% rename from lib/in_app_notification_center/bloc/in_app_notification_center_bloc.dart rename to lib/account/bloc/in_app_notification_center_bloc.dart diff --git a/lib/in_app_notification_center/bloc/in_app_notification_center_event.dart b/lib/account/bloc/in_app_notification_center_event.dart similarity index 100% rename from lib/in_app_notification_center/bloc/in_app_notification_center_event.dart rename to lib/account/bloc/in_app_notification_center_event.dart diff --git a/lib/in_app_notification_center/bloc/in_app_notification_center_state.dart b/lib/account/bloc/in_app_notification_center_state.dart similarity index 100% rename from lib/in_app_notification_center/bloc/in_app_notification_center_state.dart rename to lib/account/bloc/in_app_notification_center_state.dart diff --git a/lib/in_app_notification_center/view/in_app_notification_center_page.dart b/lib/account/view/in_app_notification_center_page.dart similarity index 98% rename from lib/in_app_notification_center/view/in_app_notification_center_page.dart rename to lib/account/view/in_app_notification_center_page.dart index 79cb6826..817aa9b5 100644 --- a/lib/in_app_notification_center/view/in_app_notification_center_page.dart +++ b/lib/account/view/in_app_notification_center_page.dart @@ -4,8 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.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/app/bloc/app_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/in_app_notification_center/bloc/in_app_notification_center_bloc.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/in_app_notification_center/widgets/in_app_notification_list_item.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/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; import 'package:go_router/go_router.dart'; diff --git a/lib/in_app_notification_center/widgets/in_app_notification_list_item.dart b/lib/account/widgets/in_app_notification_list_item.dart similarity index 100% rename from lib/in_app_notification_center/widgets/in_app_notification_list_item.dart rename to lib/account/widgets/in_app_notification_list_item.dart diff --git a/lib/router/router.dart b/lib/router/router.dart index d68d5fff..038d48dc 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -45,7 +45,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/v import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/view/source_list_filter_page.dart' as feed_filter; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/view/topic_filter_page.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/in_app_notification_center/view/in_app_notification_center_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/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/go_router_observer.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; From 9f7e04827db2b4f3b9dbb48d50614646001e8a53 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 20:03:34 +0100 Subject: [PATCH 19/41] fix(router): move notifications route inside account modal - Remove notifications route from main routes list - Add notifications route inside account modal routes - This change ensures proper navigation and back stack behavior - Fixes bug where going back from notifications page would close the app --- lib/router/router.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index 038d48dc..4dc37d6a 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -221,11 +221,6 @@ GoRouter createRouter({ ), ], ), - GoRoute( - path: Routes.notifications, - name: Routes.notificationsName, - builder: (context, state) => const InAppNotificationCenterPage(), - ), ], ), @@ -237,6 +232,11 @@ GoRouter createRouter({ pageBuilder: (context, state) => const MaterialPage(fullscreenDialog: true, child: AccountPage()), routes: [ + GoRoute( + path: Routes.notifications, + name: Routes.notificationsName, + builder: (context, state) => const InAppNotificationCenterPage(), + ), // The settings section within the account modal. It uses a // ShellRoute to provide a SettingsBloc to all its children. ShellRoute( From 91cc53891b558574deb2b496728eb275b44ae3a9 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 20:21:38 +0100 Subject: [PATCH 20/41] refactor(router): update notifications route and remove duplicate - Change route path and name from 'notifications' to 'notifications-center' - Remove duplicate notifications route defined in two places - Update import order to be consistent with other imports --- lib/router/router.dart | 16 +++------------- lib/router/routes.dart | 4 ++-- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index 4dc37d6a..a16272d1 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -11,6 +11,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'; @@ -45,7 +46,6 @@ import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/v import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/view/source_list_filter_page.dart' as feed_filter; import 'package:flutter_news_app_mobile_client_full_source_code/headlines-feed/view/topic_filter_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/l10n/l10n.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/go_router_observer.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/router/routes.dart'; @@ -233,8 +233,8 @@ GoRouter createRouter({ const MaterialPage(fullscreenDialog: true, child: AccountPage()), routes: [ GoRoute( - path: Routes.notifications, - name: Routes.notificationsName, + path: Routes.notificationsCenter, + name: Routes.notificationsCenterName, builder: (context, state) => const InAppNotificationCenterPage(), ), // The settings section within the account modal. It uses a @@ -565,16 +565,6 @@ GoRouter createRouter({ ); }, ), - // 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 8dad9d84..3730950b 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -35,8 +35,8 @@ abstract final class Routes { static const settingsName = 'settings'; static const manageFollowedItems = 'manage-followed-items'; static const manageFollowedItemsName = 'manageFollowedItems'; - static const notifications = 'notifications'; - static const notificationsName = 'notifications'; + static const notificationsCenter = 'notifications-center'; + static const notificationsCenterName = 'notificationsCenter'; // --- Relative Sub-Routes --- // These routes are defined with relative paths and are intended to be From 2fb4f05c706dd140466c346db1e7d0c03917bc89 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 20:22:15 +0100 Subject: [PATCH 21/41] fix(account): update route name for notifications - Replace incorrect route name `Routes.notificationsName` with correct `Routes.notificationsCenterName` - This change ensures proper navigation to the Notification Center page when the account notifications tile is tapped --- lib/account/view/account_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/account/view/account_page.dart b/lib/account/view/account_page.dart index 8fdea1ee..d46fcf0a 100644 --- a/lib/account/view/account_page.dart +++ b/lib/account/view/account_page.dart @@ -193,7 +193,7 @@ class AccountPage extends StatelessWidget { title: l10n.accountNotificationsTile, onTap: () { // Navigate to the new Notification Center page. - context.pushNamed(Routes.notificationsName); + context.pushNamed(Routes.notificationsCenterName); }, // Wrap the title with NotificationIndicator to show the red dot. // This ensures the indicator is aligned with the text. From 21c530477ff5c9f2f32a62f650dac1c87e0dc01b Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 20:29:31 +0100 Subject: [PATCH 22/41] refactor(account): simplify notification center - Remove 'All' tab and related logic - Consolidate notifications into breaking news and digests only - Update UI to reflect new tab structure - Improve code readability and maintainability --- .../bloc/in_app_notification_center_bloc.dart | 12 --------- .../in_app_notification_center_event.dart | 2 +- .../in_app_notification_center_state.dart | 26 ++++++------------- .../view/in_app_notification_center_page.dart | 8 +++--- 4 files changed, 12 insertions(+), 36 deletions(-) diff --git a/lib/account/bloc/in_app_notification_center_bloc.dart b/lib/account/bloc/in_app_notification_center_bloc.dart index 7fa6ed30..1e07af78 100644 --- a/lib/account/bloc/in_app_notification_center_bloc.dart +++ b/lib/account/bloc/in_app_notification_center_bloc.dart @@ -82,7 +82,6 @@ class InAppNotificationCenterBloc emit( state.copyWith( status: InAppNotificationCenterStatus.success, - notifications: allNotifications, breakingNewsNotifications: breakingNews, digestNotifications: digests, ), @@ -138,10 +137,6 @@ class InAppNotificationCenterBloc ); // Update the local state to reflect the change immediately. - final updatedList = state.notifications - .map((n) => n.id == notification.id ? updatedNotification : n) - .toList(); - final updatedBreakingNewsList = state.breakingNewsNotifications .map((n) => n.id == notification.id ? updatedNotification : n) .toList(); @@ -152,7 +147,6 @@ class InAppNotificationCenterBloc emit( state.copyWith( - notifications: updatedList, breakingNewsNotifications: updatedBreakingNewsList, digestNotifications: updatedDigestList, ), @@ -214,10 +208,6 @@ class InAppNotificationCenterBloc ); // Update local state with all notifications marked as read. - final fullyUpdatedList = state.notifications - .map((n) => n.isRead ? n : n.copyWith(readAt: now)) - .toList(); - final fullyUpdatedBreakingNewsList = state.breakingNewsNotifications .map((n) => n.isRead ? n : n.copyWith(readAt: now)) .toList(); @@ -225,10 +215,8 @@ class InAppNotificationCenterBloc final fullyUpdatedDigestList = state.digestNotifications .map((n) => n.isRead ? n : n.copyWith(readAt: now)) .toList(); - emit( state.copyWith( - notifications: fullyUpdatedList, breakingNewsNotifications: fullyUpdatedBreakingNewsList, digestNotifications: fullyUpdatedDigestList, ), diff --git a/lib/account/bloc/in_app_notification_center_event.dart b/lib/account/bloc/in_app_notification_center_event.dart index e8f046f8..b7e816f4 100644 --- a/lib/account/bloc/in_app_notification_center_event.dart +++ b/lib/account/bloc/in_app_notification_center_event.dart @@ -36,7 +36,7 @@ class InAppNotificationCenterMarkAllAsRead class InAppNotificationCenterTabChanged extends InAppNotificationCenterEvent { const InAppNotificationCenterTabChanged(this.tabIndex); - /// The index of the newly selected tab. + /// The index of the newly selected tab. 0: Breaking News, 1: Digests. final int tabIndex; @override diff --git a/lib/account/bloc/in_app_notification_center_state.dart b/lib/account/bloc/in_app_notification_center_state.dart index fa99fe76..ded68a2e 100644 --- a/lib/account/bloc/in_app_notification_center_state.dart +++ b/lib/account/bloc/in_app_notification_center_state.dart @@ -22,15 +22,14 @@ class InAppNotificationCenterState extends Equatable { /// {@macro in_app_notification_center_state} const InAppNotificationCenterState({ this.status = InAppNotificationCenterStatus.initial, - this.currentTabIndex = 0, - this.notifications = const [], this.breakingNewsNotifications = const [], - this.error, this.digestNotifications = const [], + this.currentTabIndex = 0, + this.error, }); /// The currently selected tab index. - /// 0: All, 1: Breaking News, 2: Digests. + /// 0: Breaking News, 1: Digests. final int currentTabIndex; /// The list of breaking news notifications. @@ -39,20 +38,14 @@ class InAppNotificationCenterState extends Equatable { /// The list of digest notifications (daily and weekly roundups). final List digestNotifications; - /// Returns the list of notifications filtered by the current tab. - List get filteredNotifications { - return switch (currentTabIndex) { - 1 => breakingNewsNotifications, - 2 => digestNotifications, - _ => notifications, // Default to 'All' tab - }; - } - /// The current status of the notification center. final InAppNotificationCenterStatus status; - /// The list of notifications. - final List notifications; + /// The combined list of all notifications. + List get notifications => [ + ...breakingNewsNotifications, + ...digestNotifications, + ]; /// An error that occurred during notification loading or processing. final HttpException? error; @@ -60,7 +53,6 @@ class InAppNotificationCenterState extends Equatable { @override List get props => [ status, - notifications, currentTabIndex, breakingNewsNotifications, digestNotifications, @@ -71,7 +63,6 @@ class InAppNotificationCenterState extends Equatable { /// values. InAppNotificationCenterState copyWith({ InAppNotificationCenterStatus? status, - List? notifications, HttpException? error, int? currentTabIndex, List? breakingNewsNotifications, @@ -79,7 +70,6 @@ class InAppNotificationCenterState extends Equatable { }) { return InAppNotificationCenterState( status: status ?? this.status, - notifications: notifications ?? this.notifications, error: error ?? this.error, currentTabIndex: currentTabIndex ?? this.currentTabIndex, breakingNewsNotifications: diff --git a/lib/account/view/in_app_notification_center_page.dart b/lib/account/view/in_app_notification_center_page.dart index 817aa9b5..58f85b83 100644 --- a/lib/account/view/in_app_notification_center_page.dart +++ b/lib/account/view/in_app_notification_center_page.dart @@ -35,7 +35,7 @@ class _InAppNotificationCenterPageState @override void initState() { super.initState(); - _tabController = TabController(length: 3, vsync: this) + _tabController = TabController(length: 2, vsync: this) ..addListener(() { if (!_tabController.indexIsChanging) { context.read().add( @@ -72,7 +72,7 @@ class _InAppNotificationCenterPageState >( builder: (context, state) { final hasUnread = state.notifications.any((n) => !n.isRead); - return TextButton( + return IconButton( onPressed: hasUnread ? () { context.read().add( @@ -80,7 +80,7 @@ class _InAppNotificationCenterPageState ); } : null, - child: Text(l10n.notificationCenterMarkAllAsReadButton), + icon: const Icon(Icons.done_all), ); }, ), @@ -88,7 +88,6 @@ class _InAppNotificationCenterPageState bottom: TabBar( controller: _tabController, tabs: [ - Tab(text: l10n.notificationCenterTabAll), Tab(text: l10n.notificationCenterTabBreakingNews), Tab(text: l10n.notificationCenterTabDigests), ], @@ -139,7 +138,6 @@ class _InAppNotificationCenterPageState return TabBarView( controller: _tabController, children: [ - _NotificationList(notifications: state.notifications), _NotificationList( notifications: state.breakingNewsNotifications, ), From 74702ced34d241a09537421e5674a1af2ddf6b66 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 20:40:18 +0100 Subject: [PATCH 23/41] build(l10n): updated --- lib/account/view/account_page.dart | 2 +- lib/l10n/app_localizations.dart | 8 +------- lib/l10n/app_localizations_ar.dart | 9 +++------ lib/l10n/app_localizations_en.dart | 5 +---- lib/l10n/arb/app_ar.arb | 10 +++------- lib/l10n/arb/app_en.arb | 6 +----- 6 files changed, 10 insertions(+), 30 deletions(-) diff --git a/lib/account/view/account_page.dart b/lib/account/view/account_page.dart index d46fcf0a..36a2bc23 100644 --- a/lib/account/view/account_page.dart +++ b/lib/account/view/account_page.dart @@ -172,7 +172,7 @@ class AccountPage extends StatelessWidget { children: [ buildTile( context: context, - icon: Icons.tune_outlined, + icon: Icons.check_outlined, title: l10n.accountContentPreferencesTile, onTap: () => context.pushNamed(Routes.manageFollowedItemsName), ), diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b96d8660..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 @@ -2510,12 +2510,6 @@ abstract class AppLocalizations { /// **'You have no new notifications.'** String get notificationCenterEmptySubheadline; - /// Label for the 'All' tab in the notification center. - /// - /// In en, this message translates to: - /// **'All'** - String get notificationCenterTabAll; - /// Label for the 'Breaking News' tab in the notification center. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 82933fb3..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) { @@ -1322,10 +1322,7 @@ class AppLocalizationsAr extends AppLocalizations { String get notificationCenterEmptySubheadline => 'ليس لديك إشعارات جديدة.'; @override - String get notificationCenterTabAll => 'الكل'; - - @override - String get notificationCenterTabBreakingNews => 'الأخبار العاجلة'; + String get notificationCenterTabBreakingNews => 'العواجل'; @override String get notificationCenterTabDigests => 'الملخصات'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 67382ff4..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'; @@ -1325,9 +1325,6 @@ class AppLocalizationsEn extends AppLocalizations { String get notificationCenterEmptySubheadline => 'You have no new notifications.'; - @override - String get notificationCenterTabAll => 'All'; - @override String get notificationCenterTabBreakingNews => 'Breaking News'; diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 48e54f28..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" }, @@ -1714,11 +1714,7 @@ "@notificationCenterEmptySubheadline": { "description": "Subheadline for the empty state on the notification center page." }, - "notificationCenterTabAll": "الكل", - "@notificationCenterTabAll": { - "description": "Label for the 'All' tab in the notification center." - }, - "notificationCenterTabBreakingNews": "الأخبار العاجلة", + "notificationCenterTabBreakingNews": "العواجل", "@notificationCenterTabBreakingNews": { "description": "Label for the 'Breaking News' tab in the notification center." }, diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 86510621..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" }, @@ -1714,10 +1714,6 @@ "@notificationCenterEmptySubheadline": { "description": "Subheadline for the empty state on the notification center page." }, - "notificationCenterTabAll": "All", - "@notificationCenterTabAll": { - "description": "Label for the 'All' tab in the notification center." - }, "notificationCenterTabBreakingNews": "Breaking News", "@notificationCenterTabBreakingNews": { "description": "Label for the 'Breaking News' tab in the notification center." From 82be099664a3910be96960db71ac8a6f320f8493 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 20:56:19 +0100 Subject: [PATCH 24/41] fix(demo): initialize in-app notifications for new users - Add in-app notification initialization to the DemoDataInitializerService - Update bootstrap.dart to include in-app notification repository and fixtures - Implement logic to clone and assign notifications from fixture data for new users --- .../demo_data_initializer_service.dart | 55 +++++++++++++++++++ lib/bootstrap.dart | 5 +- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/lib/app/services/demo_data_initializer_service.dart b/lib/app/services/demo_data_initializer_service.dart index ca5df0ad..5d5d05b9 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,49 @@ 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.', + ); + + // Clone each notification from the fixture data, assigning the new user's ID. + final userNotifications = inAppNotificationsFixturesData + .map((n) => n.copyWith(userId: userId)) + .toList(); + + // Create all the new notifications for the user. + 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/bootstrap.dart b/lib/bootstrap.dart index 3674c676..d7e83266 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -289,9 +289,8 @@ Future bootstrap( inAppNotificationClient = DataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, - initialData: inAppNotificationsFixturesData, logger: logger, - ); + ); pushNotificationDeviceClient = DataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, @@ -521,9 +520,11 @@ Future bootstrap( ? DemoDataInitializerService( userAppSettingsRepository: userAppSettingsRepository, userContentPreferencesRepository: userContentPreferencesRepository, + inAppNotificationRepository: inAppNotificationRepository, userAppSettingsFixturesData: userAppSettingsFixturesData, userContentPreferencesFixturesData: userContentPreferencesFixturesData, + inAppNotificationsFixturesData: inAppNotificationsFixturesData, ) : null; logger From 3f2a9009f8d4115c595a36c630d348be3fd64fdd Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 21:12:41 +0100 Subject: [PATCH 25/41] fix(account): update in-app notification filtering logic - Replace deliveryType-based filtering with contentType-based filtering - Simplify notification categorization into breakingNews and digests - Ensure all notification types are visible to the user --- .../bloc/in_app_notification_center_bloc.dart | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/lib/account/bloc/in_app_notification_center_bloc.dart b/lib/account/bloc/in_app_notification_center_bloc.dart index 1e07af78..a2b29bda 100644 --- a/lib/account/bloc/in_app_notification_center_bloc.dart +++ b/lib/account/bloc/in_app_notification_center_bloc.dart @@ -61,23 +61,21 @@ class InAppNotificationCenterBloc final allNotifications = response.items; - // Filter notifications into their respective categories based on the - // deliveryType specified in the payload's data map. - final breakingNews = allNotifications - .where( - (n) => - n.payload.data['deliveryType'] == - PushNotificationSubscriptionDeliveryType.breakingOnly.name, - ) - .toList(); + final breakingNews = []; + final digests = []; - final digests = allNotifications.where((n) { - final deliveryType = n.payload.data['deliveryType'] as String?; - return deliveryType == - PushNotificationSubscriptionDeliveryType.dailyDigest.name || - deliveryType == - PushNotificationSubscriptionDeliveryType.weeklyRoundup.name; - }).toList(); + // Filter notifications into their respective categories based on the + // contentType specified in the payload's data map. + for (final n in allNotifications) { + final contentType = n.payload.data['contentType'] as String?; + if (contentType == 'digest') { + digests.add(n); + } else { + // Treat 'headline' and any other unknown types as breaking news + // to ensure all notifications are visible to the user. + breakingNews.add(n); + } + } emit( state.copyWith( From 110ab77f975d7a645d2940c5d57e7065d0414b07 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 21:22:13 +0100 Subject: [PATCH 26/41] feat(notifications): simulate push notifications in demo mode - Enhance NoOpPushNotificationService to simulate push notifications in demo environment - Update DemoDataInitializerService to handle empty inAppNotificationsFixturesData - Modify bootstrap.dart to pass additional parameters to NoOpPushNotificationService - Improve logging and add stream handling for simulated push notifications --- .../demo_data_initializer_service.dart | 16 ++++-- lib/bootstrap.dart | 14 +++-- .../no_op_push_notification_service.dart | 53 ++++++++++++++++--- 3 files changed, 71 insertions(+), 12 deletions(-) diff --git a/lib/app/services/demo_data_initializer_service.dart b/lib/app/services/demo_data_initializer_service.dart index 5d5d05b9..7896f985 100644 --- a/lib/app/services/demo_data_initializer_service.dart +++ b/lib/app/services/demo_data_initializer_service.dart @@ -177,12 +177,22 @@ class DemoDataInitializerService { 'No InAppNotifications found for user ID: $userId. Creating from fixture.', ); - // Clone each notification from the fixture data, assigning the new user's ID. - final userNotifications = inAppNotificationsFixturesData + 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(); - // Create all the new notifications for the user. await Future.wait( userNotifications.map( (n) => _inAppNotificationRepository.create(item: n, userId: userId), diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index d7e83266..2e462ee6 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -290,7 +290,7 @@ Future bootstrap( toJson: (i) => i.toJson(), getId: (i) => i.id, logger: logger, - ); + ); pushNotificationDeviceClient = DataInMemory( toJson: (i) => i.toJson(), getId: (i) => i.id, @@ -470,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) { @@ -495,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 diff --git a/lib/notifications/services/no_op_push_notification_service.dart b/lib/notifications/services/no_op_push_notification_service.dart index 065be263..0c7f0c4c 100644 --- a/lib/notifications/services/no_op_push_notification_service.dart +++ b/lib/notifications/services/no_op_push_notification_service.dart @@ -1,6 +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/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/notifications/services/push_notification_service.dart'; /// {@template no_op_push_notification_service} @@ -12,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 {} @@ -34,10 +55,29 @@ 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; + } + Future.delayed(const Duration(seconds: 3), () { + if (_inAppNotificationsFixturesData.isEmpty) return; + + // Use the first notification from the fixtures as the simulated push. + final notificationToSimulate = _inAppNotificationsFixturesData.first; + + // The AppBloc listens to the `onMessage` stream. When a payload is + // emitted, the BLoC will create a new InAppNotification, save it, + // and update the UI to show the unread indicator. + _onMessageController.add(notificationToSimulate.payload); + }); + } @override - Stream get onMessage => const Stream.empty(); + Future close() async { + await _onMessageController.close(); + } @override Stream get onMessageOpenedApp => @@ -50,8 +90,9 @@ class NoOpPushNotificationService extends PushNotificationService { Future get initialMessage async => null; @override - Future close() async {} - - @override - List get props => []; + List get props => [ + _inAppNotificationRepository, + _inAppNotificationsFixturesData, + environment, + ]; } From dde131dd423d2ac5afe8646e18ab03768114e0a9 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 21:22:22 +0100 Subject: [PATCH 27/41] style: format --- lib/account/view/in_app_notification_center_page.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/account/view/in_app_notification_center_page.dart b/lib/account/view/in_app_notification_center_page.dart index 58f85b83..2ac5a52a 100644 --- a/lib/account/view/in_app_notification_center_page.dart +++ b/lib/account/view/in_app_notification_center_page.dart @@ -2,10 +2,10 @@ import 'package:core/core.dart'; 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/ads/services/interstitial_ad_manager.dart'; -import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_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/app/bloc/app_bloc.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'; From c6d07e3a90d6d7d3c04fc272ef3efe40c8d7bb62 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 21:25:11 +0100 Subject: [PATCH 28/41] test(notifications): increase delay for in-app notifications in demo mode - Change the delay from 3 to 10 seconds for in-app notifications in demo mode - This modification helps frontend developers by providing more time to - observe and test the notification flow --- lib/notifications/services/no_op_push_notification_service.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/notifications/services/no_op_push_notification_service.dart b/lib/notifications/services/no_op_push_notification_service.dart index 0c7f0c4c..8e7f2b32 100644 --- a/lib/notifications/services/no_op_push_notification_service.dart +++ b/lib/notifications/services/no_op_push_notification_service.dart @@ -61,7 +61,7 @@ class NoOpPushNotificationService extends PushNotificationService { if (environment != AppEnvironment.demo) { return; } - Future.delayed(const Duration(seconds: 3), () { + Future.delayed(const Duration(seconds: 10), () { if (_inAppNotificationsFixturesData.isEmpty) return; // Use the first notification from the fixtures as the simulated push. From 24d15d1b18d65a7428e920d82082790192baa805 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 21:30:45 +0100 Subject: [PATCH 29/41] feat(README): update feature list and description - Remove foreground notification handling feature - Add integrated notification center feature with subtle in-app indicator and unread count - Improve description for better clarity --- README.md | 2 +- lib/notifications/services/no_op_push_notification_service.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/notifications/services/no_op_push_notification_service.dart b/lib/notifications/services/no_op_push_notification_service.dart index 8e7f2b32..ae79ea39 100644 --- a/lib/notifications/services/no_op_push_notification_service.dart +++ b/lib/notifications/services/no_op_push_notification_service.dart @@ -1,9 +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/app/bloc/app_bloc.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/notifications/services/push_notification_service.dart'; /// {@template no_op_push_notification_service} From a417279e12d520893f9da67e36ec11629ad159e6 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 21:41:16 +0100 Subject: [PATCH 30/41] fix(account): handle null case for notification in markAsRead - Use firstWhereOrNull instead of firstWhere to safely handle null case - Add logging for attempted markAsRead on non-existent notification - Prevent exception by returning early if notification is not found --- .../bloc/in_app_notification_center_bloc.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/account/bloc/in_app_notification_center_bloc.dart b/lib/account/bloc/in_app_notification_center_bloc.dart index a2b29bda..f2f4d6ee 100644 --- a/lib/account/bloc/in_app_notification_center_bloc.dart +++ b/lib/account/bloc/in_app_notification_center_bloc.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; import 'package:core/core.dart'; +import 'package:collection/collection.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'; @@ -117,11 +118,18 @@ class InAppNotificationCenterBloc InAppNotificationCenterMarkedAsRead event, Emitter emit, ) async { - final notification = state.notifications.firstWhere( + final notification = state.notifications.firstWhereOrNull( (n) => n.id == event.notificationId, - orElse: () => throw Exception('Notification not found in state'), ); + 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; From 0cf733777e7fc995f7d88adf4acff556c8ec3a33 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 21:41:47 +0100 Subject: [PATCH 31/41] refactor(app): replace Random ID generation with UUID - Remove unnecessary import of dart:math - Add uuid package to dependencies - Update InAppNotification ID generation to use UUID instead of random number - Add Uuid as a dependency in AppBloc constructor --- lib/app/bloc/app_bloc.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 18a085ed..94fca98f 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:math'; import 'package:auth_repository/auth_repository.dart'; import 'package:core/core.dart'; @@ -15,6 +14,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/app/services/app import 'package:flutter_news_app_mobile_client_full_source_code/notifications/services/push_notification_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/extensions/extensions.dart'; import 'package:logging/logging.dart'; +import 'package:uuid/uuid.dart'; part 'app_event.dart'; part 'app_state.dart'; @@ -49,6 +49,7 @@ class AppBloc extends Bloc { required DataRepository userRepository, required PushNotificationService pushNotificationService, required DataRepository inAppNotificationRepository, + Uuid? uuid, }) : _remoteConfigRepository = remoteConfigRepository, _appInitializer = appInitializer, _authRepository = authRepository, @@ -57,6 +58,7 @@ class AppBloc extends Bloc { _userRepository = userRepository, _inAppNotificationRepository = inAppNotificationRepository, _pushNotificationService = pushNotificationService, + _uuid = uuid ?? const Uuid(), _inlineAdCacheService = inlineAdCacheService, _logger = logger, super( @@ -110,8 +112,7 @@ class AppBloc extends Bloc { try { final newNotification = InAppNotification( // Generate a random ID for the notification. - // In a real-world scenario, this would likely be a UUID. - id: Random().nextInt(999999).toString(), + id: _uuid.v4(), userId: state.user!.id, payload: payload, createdAt: DateTime.now(), @@ -147,6 +148,7 @@ class AppBloc extends Bloc { final DataRepository _userRepository; final DataRepository _inAppNotificationRepository; final PushNotificationService _pushNotificationService; + final Uuid _uuid; final InlineAdCacheService _inlineAdCacheService; /// Handles the [AppStarted] event. From 985e2bdd10a7f181d71490df5b178bddff53cfa9 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 21:54:42 +0100 Subject: [PATCH 32/41] fix(account): move InAppNotificationCenterBlocProvider to router - Move BlocProvider from InAppNotificationCenterPage to router - Ensure InAppNotificationCenterBloc is available in BuildContext when InAppNotificationCenterPage's initState runs - Refactor InAppNotificationCenterPage widget build --- .../view/in_app_notification_center_page.dart | 111 ++++++++---------- lib/router/router.dart | 14 ++- 2 files changed, 64 insertions(+), 61 deletions(-) diff --git a/lib/account/view/in_app_notification_center_page.dart b/lib/account/view/in_app_notification_center_page.dart index 2ac5a52a..4934c1ad 100644 --- a/lib/account/view/in_app_notification_center_page.dart +++ b/lib/account/view/in_app_notification_center_page.dart @@ -55,15 +55,8 @@ class _InAppNotificationCenterPageState Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; - return BlocProvider( - create: (context) => InAppNotificationCenterBloc( - inAppNotificationRepository: context - .read>(), - appBloc: context.read(), - logger: context.read(), - )..add(const InAppNotificationCenterSubscriptionRequested()), - child: Scaffold( - appBar: AppBar( + return Scaffold( + appBar: AppBar( title: Text(l10n.notificationCenterPageTitle), actions: [ BlocBuilder< @@ -93,59 +86,57 @@ class _InAppNotificationCenterPageState ], ), ), - 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), - ], + 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), + ], + ); + }, ), ); } diff --git a/lib/router/router.dart b/lib/router/router.dart index a16272d1..c63e1584 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'; @@ -235,7 +236,18 @@ GoRouter createRouter({ GoRoute( path: Routes.notificationsCenter, name: Routes.notificationsCenterName, - builder: (context, state) => const InAppNotificationCenterPage(), + 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. From 48c59f0b9adb279522f91303af1a165d9e01ad0f Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 22:10:12 +0100 Subject: [PATCH 33/41] refactor(account): improve notification filtering logic - Prioritize 'notificationType' from the backend over 'contentType' - Explicitly check for 'dailyDigest' and 'weeklyRoundup' notification types - Maintain existing behavior for breaking news and headline content --- .../bloc/in_app_notification_center_bloc.dart | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/account/bloc/in_app_notification_center_bloc.dart b/lib/account/bloc/in_app_notification_center_bloc.dart index f2f4d6ee..faca823b 100644 --- a/lib/account/bloc/in_app_notification_center_bloc.dart +++ b/lib/account/bloc/in_app_notification_center_bloc.dart @@ -65,15 +65,21 @@ class InAppNotificationCenterBloc final breakingNews = []; final digests = []; - // Filter notifications into their respective categories based on the - // contentType specified in the payload's data map. + // 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 (contentType == 'digest') { + + if (notificationType == + PushNotificationSubscriptionDeliveryType.dailyDigest.name || + notificationType == + PushNotificationSubscriptionDeliveryType.weeklyRoundup.name || + contentType == 'digest') { digests.add(n); } else { - // Treat 'headline' and any other unknown types as breaking news - // to ensure all notifications are visible to the user. + // All other types (including 'breakingOnly' notificationType, + // 'headline' contentType, or any unknown types) go to breaking news. breakingNews.add(n); } } From 35e5a8e189a783c225f8115ae680f25f2353588f Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 23:54:10 +0100 Subject: [PATCH 34/41] refactor(app): remove in-app notification persistence from client - Remove Uuid dependency from AppBloc - Remove notification persistence logic from push notification handler - Update comment to reflect new backend responsibility for notification persistence --- lib/app/bloc/app_bloc.dart | 35 ++++------------------------------- 1 file changed, 4 insertions(+), 31 deletions(-) diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 94fca98f..a262dbd0 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -14,7 +14,6 @@ import 'package:flutter_news_app_mobile_client_full_source_code/app/services/app import 'package:flutter_news_app_mobile_client_full_source_code/notifications/services/push_notification_service.dart'; import 'package:flutter_news_app_mobile_client_full_source_code/shared/extensions/extensions.dart'; import 'package:logging/logging.dart'; -import 'package:uuid/uuid.dart'; part 'app_event.dart'; part 'app_state.dart'; @@ -49,7 +48,6 @@ class AppBloc extends Bloc { required DataRepository userRepository, required PushNotificationService pushNotificationService, required DataRepository inAppNotificationRepository, - Uuid? uuid, }) : _remoteConfigRepository = remoteConfigRepository, _appInitializer = appInitializer, _authRepository = authRepository, @@ -58,7 +56,6 @@ class AppBloc extends Bloc { _userRepository = userRepository, _inAppNotificationRepository = inAppNotificationRepository, _pushNotificationService = pushNotificationService, - _uuid = uuid ?? const Uuid(), _inlineAdCacheService = inlineAdCacheService, _logger = logger, super( @@ -108,33 +105,10 @@ class AppBloc extends Bloc { // Listen to raw foreground push notifications. _pushNotificationService.onMessage.listen((payload) async { _logger.fine('AppBloc received foreground push notification payload.'); - if (state.user != null) { - try { - final newNotification = InAppNotification( - // Generate a random ID for the notification. - id: _uuid.v4(), - userId: state.user!.id, - payload: payload, - createdAt: DateTime.now(), - ); - - await _inAppNotificationRepository.create( - item: newNotification, - userId: state.user!.id, - ); - _logger.info( - 'Successfully persisted in-app notification for user ${state.user!.id}.', - ); - // Notify the app that a new notification has been received to update UI. - add(const AppInAppNotificationReceived()); - } catch (e, s) { - _logger.severe( - 'Failed to persist in-app notification from foreground message.', - e, - s, - ); - } - } + // 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()); }); } @@ -148,7 +122,6 @@ class AppBloc extends Bloc { final DataRepository _userRepository; final DataRepository _inAppNotificationRepository; final PushNotificationService _pushNotificationService; - final Uuid _uuid; final InlineAdCacheService _inlineAdCacheService; /// Handles the [AppStarted] event. From 5411896aa6e30deef063d2376a0f5ce559bc5ff5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 23:55:17 +0100 Subject: [PATCH 35/41] feat(account): add support for marking a single notification as read from a deep-link - Implement new event InAppNotificationCenterMarkOneAsRead - Add new handler _onMarkOneAsRead in InAppNotificationCenterBloc - Extract shared logic to _markOneAsRead helper method - Update imports to follow package order --- .../bloc/in_app_notification_center_bloc.dart | 27 ++++++++++++++++++- .../in_app_notification_center_event.dart | 13 +++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/lib/account/bloc/in_app_notification_center_bloc.dart b/lib/account/bloc/in_app_notification_center_bloc.dart index faca823b..d840620e 100644 --- a/lib/account/bloc/in_app_notification_center_bloc.dart +++ b/lib/account/bloc/in_app_notification_center_bloc.dart @@ -1,8 +1,8 @@ import 'dart:async'; import 'package:bloc/bloc.dart'; -import 'package:core/core.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'; @@ -34,6 +34,7 @@ class InAppNotificationCenterBloc on(_onMarkedAsRead); on(_onMarkAllAsRead); on(_onTabChanged); + on(_onMarkOneAsRead); } final DataRepository _inAppNotificationRepository; @@ -128,6 +129,18 @@ class InAppNotificationCenterBloc (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 ' @@ -139,6 +152,18 @@ class InAppNotificationCenterBloc // 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 { diff --git a/lib/account/bloc/in_app_notification_center_event.dart b/lib/account/bloc/in_app_notification_center_event.dart index b7e816f4..f44ca1e7 100644 --- a/lib/account/bloc/in_app_notification_center_event.dart +++ b/lib/account/bloc/in_app_notification_center_event.dart @@ -42,3 +42,16 @@ class InAppNotificationCenterTabChanged extends InAppNotificationCenterEvent { @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]; +} From 934323e07de58d722319e5e77d601c6a0be53a50 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 23:56:03 +0100 Subject: [PATCH 36/41] feat(notification): handle notification payload in app routing - Add handling for 'notificationId' in notification payload - Use pushNamed instead of goNamed for navigation to ensure correct context - Pass 'notificationId' as extra data when navigating to article details --- lib/app/view/app.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 1b34dccd..c393ad0b 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -222,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}, ); } }); From ee93b90df4e4d390c16edcf3949a183b40d62938 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 23:56:44 +0100 Subject: [PATCH 37/41] feat(headline-details): handle deep linking from notifications - Add notificationId parameter to HeadlineDetailsPage - Mark in-app notification as read when navigating from a push notification - Import InAppNotificationCenterBloc for handling notification state --- .../view/headline_details_page.dart | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) 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 From f83842400ee014a74deb13e4b84fb446d7682b02 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 23:57:21 +0100 Subject: [PATCH 38/41] feat(router): enhance navigation handling for deep links - Add support for push notification deep-links in account and global article details routes - Improve type safety for route 'extra' parameters - Refactor BlocProvider creations for better readability - Extract common provider setup for entity and headline details pages --- lib/router/router.dart | 110 +++++++++++++++++++++++------------------ 1 file changed, 63 insertions(+), 47 deletions(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index c63e1584..c22a44d0 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -241,7 +241,8 @@ GoRouter createRouter({ // in the BuildContext when InAppNotificationCenterPage's initState runs. return BlocProvider( create: (context) => InAppNotificationCenterBloc( - inAppNotificationRepository: context.read>(), + inAppNotificationRepository: + context.read>(), appBloc: context.read(), logger: context.read(), )..add(const InAppNotificationCenterSubscriptionRequested()), @@ -259,8 +260,8 @@ GoRouter createRouter({ return BlocProvider( create: (context) { final settingsBloc = SettingsBloc( - userAppSettingsRepository: context - .read>(), + userAppSettingsRepository: + context.read>(), inlineAdCacheService: context.read(), ); if (userId != null) { @@ -356,7 +357,8 @@ GoRouter createRouter({ GoRoute( path: Routes.addCountryToFollow, name: Routes.addCountryToFollowName, - builder: (context, state) => const AddCountryToFollowPage(), + builder: (context, state) => + const AddCountryToFollowPage(), ), ], ), @@ -371,26 +373,31 @@ 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( create: (context) => HeadlineDetailsBloc( - headlinesRepository: context - .read>(), + headlinesRepository: + context.read>(), ), ), BlocProvider( create: (context) => SimilarHeadlinesBloc( - headlinesRepository: context - .read>(), + headlinesRepository: + context.read>(), ), ), ], child: HeadlineDetailsPage( initialHeadline: headlineFromExtra, headlineId: headlineFromExtra?.id ?? headlineIdFromPath, + notificationId: notificationId, ), ); }, @@ -429,25 +436,22 @@ GoRouter createRouter({ return MultiBlocProvider( providers: [ BlocProvider( - create: (context) => - EntityDetailsBloc( - headlinesRepository: context - .read>(), - topicRepository: context.read>(), - sourceRepository: context.read>(), - countryRepository: context - .read>(), - appBloc: context.read(), - adService: context.read(), - inlineAdCacheService: context - .read(), - )..add( - EntityDetailsLoadRequested( - entityId: args.entityId, - contentType: args.contentType, - adThemeStyle: adThemeStyle, - ), + create: (context) => EntityDetailsBloc( + headlinesRepository: context.read>(), + topicRepository: context.read>(), + sourceRepository: context.read>(), + countryRepository: context.read>(), + appBloc: context.read(), + adService: context.read(), + inlineAdCacheService: + context.read(), + )..add( + EntityDetailsLoadRequested( + entityId: args.entityId, + contentType: args.contentType, + adThemeStyle: adThemeStyle, ), + ), ), ], child: EntityDetailsPage(args: args), @@ -458,8 +462,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: [ @@ -477,6 +487,7 @@ GoRouter createRouter({ child: HeadlineDetailsPage( initialHeadline: headlineFromExtra, headlineId: headlineFromExtra?.id ?? headlineIdFromPath, + notificationId: notificationId, ), ); }, @@ -490,8 +501,7 @@ GoRouter createRouter({ final allItems = extra['allItems'] as List? ?? []; final initialSelectedItems = extra['initialSelectedItems'] as Set? ?? {}; - final itemBuilder = - extra['itemBuilder'] as Function? ?? + final itemBuilder = extra['itemBuilder'] as Function? ?? (dynamic item) => item.toString(); return MultiSelectSearchPage( @@ -525,13 +535,13 @@ GoRouter createRouter({ final initialUserContentPreferences = appBloc.state.userContentPreferences; return HeadlinesFeedBloc( - headlinesRepository: context - .read>(), + headlinesRepository: + context.read>(), feedDecoratorService: FeedDecoratorService(), adService: context.read(), appBloc: appBloc, - inlineAdCacheService: context - .read(), + inlineAdCacheService: + context.read(), feedCacheService: context.read(), initialUserContentPreferences: initialUserContentPreferences, @@ -551,21 +561,26 @@ 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: [ BlocProvider( create: (context) => HeadlineDetailsBloc( - headlinesRepository: context - .read>(), + headlinesRepository: + context.read>(), ), ), BlocProvider( create: (context) => SimilarHeadlinesBloc( - headlinesRepository: context - .read>(), + headlinesRepository: + context.read>(), ), ), ], @@ -573,6 +588,7 @@ GoRouter createRouter({ initialHeadline: headlineFromExtra, headlineId: headlineFromExtra?.id ?? headlineIdFromPath, + notificationId: notificationId, ), ); }, @@ -637,22 +653,22 @@ GoRouter createRouter({ builder: (context, state) { final extra = state.extra as Map? ?? - {}; + {}; final allCountries = extra['allCountries'] as List? ?? - []; + []; final allSourceTypes = extra['allSourceTypes'] - as List? ?? - []; + as List? ?? + []; final initialSelectedHeadquarterCountries = extra['initialSelectedHeadquarterCountries'] - as Set? ?? - {}; + as Set? ?? + {}; final initialSelectedSourceTypes = extra['initialSelectedSourceTypes'] - as Set? ?? - {}; + as Set? ?? + {}; return feed_filter.SourceListFilterPage( allCountries: allCountries, From 9ef0b34481a1dbcc9d5317af1d5164826c597d3b Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 23:58:00 +0100 Subject: [PATCH 39/41] fix(notifications): simulate backend behavior in NoOp service - Align the NoOp service's behavior with the backend architecture - Persist notification in the in-memory repository before emitting the push payload - This change ensures a more accurate simulation of the real push notification process --- .../no_op_push_notification_service.dart | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/lib/notifications/services/no_op_push_notification_service.dart b/lib/notifications/services/no_op_push_notification_service.dart index ae79ea39..f553e449 100644 --- a/lib/notifications/services/no_op_push_notification_service.dart +++ b/lib/notifications/services/no_op_push_notification_service.dart @@ -61,17 +61,24 @@ class NoOpPushNotificationService extends PushNotificationService { if (environment != AppEnvironment.demo) { return; } - Future.delayed(const Duration(seconds: 10), () { - if (_inAppNotificationsFixturesData.isEmpty) return; - - // Use the first notification from the fixtures as the simulated push. - final notificationToSimulate = _inAppNotificationsFixturesData.first; - - // The AppBloc listens to the `onMessage` stream. When a payload is - // emitted, the BLoC will create a new InAppNotification, save it, - // and update the UI to show the unread indicator. - _onMessageController.add(notificationToSimulate.payload); - }); + 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 From c34e26d120d69752ed97d55cee6c6f2e8d7feae2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 23:58:23 +0100 Subject: [PATCH 40/41] build(deps): update core dependency to b01f9b4 - Update core dependency in pubspec.yaml and pubspec.lock - Change ref from f409be2 to b01f9b4 in both files --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 From 13c85cccbfedf8fa92dc3be223f70edea9331854 Mon Sep 17 00:00:00 2001 From: fulleni Date: Thu, 20 Nov 2025 23:59:08 +0100 Subject: [PATCH 41/41] style: format --- .../view/in_app_notification_center_page.dart | 160 +++++++++--------- lib/router/router.dart | 92 +++++----- 2 files changed, 127 insertions(+), 125 deletions(-) diff --git a/lib/account/view/in_app_notification_center_page.dart b/lib/account/view/in_app_notification_center_page.dart index 4934c1ad..e8d8af8a 100644 --- a/lib/account/view/in_app_notification_center_page.dart +++ b/lib/account/view/in_app_notification_center_page.dart @@ -1,15 +1,12 @@ import 'package:core/core.dart'; -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/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/app/bloc/app_bloc.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:logging/logging.dart'; import 'package:ui_kit/ui_kit.dart'; /// {@template in_app_notification_center_page} @@ -57,87 +54,88 @@ class _InAppNotificationCenterPageState 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), - ], + 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), - ], - ); - }, ), + 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), + ], + ); + }, + ), ); } } diff --git a/lib/router/router.dart b/lib/router/router.dart index c22a44d0..a8ef3b20 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -241,8 +241,8 @@ GoRouter createRouter({ // in the BuildContext when InAppNotificationCenterPage's initState runs. return BlocProvider( create: (context) => InAppNotificationCenterBloc( - inAppNotificationRepository: - context.read>(), + inAppNotificationRepository: context + .read>(), appBloc: context.read(), logger: context.read(), )..add(const InAppNotificationCenterSubscriptionRequested()), @@ -260,8 +260,8 @@ GoRouter createRouter({ return BlocProvider( create: (context) { final settingsBloc = SettingsBloc( - userAppSettingsRepository: - context.read>(), + userAppSettingsRepository: context + .read>(), inlineAdCacheService: context.read(), ); if (userId != null) { @@ -357,8 +357,7 @@ GoRouter createRouter({ GoRoute( path: Routes.addCountryToFollow, name: Routes.addCountryToFollowName, - builder: (context, state) => - const AddCountryToFollowPage(), + builder: (context, state) => const AddCountryToFollowPage(), ), ], ), @@ -383,14 +382,14 @@ GoRouter createRouter({ providers: [ BlocProvider( create: (context) => HeadlineDetailsBloc( - headlinesRepository: - context.read>(), + headlinesRepository: context + .read>(), ), ), BlocProvider( create: (context) => SimilarHeadlinesBloc( - headlinesRepository: - context.read>(), + headlinesRepository: context + .read>(), ), ), ], @@ -436,22 +435,25 @@ GoRouter createRouter({ return MultiBlocProvider( providers: [ BlocProvider( - create: (context) => EntityDetailsBloc( - headlinesRepository: context.read>(), - topicRepository: context.read>(), - sourceRepository: context.read>(), - countryRepository: context.read>(), - appBloc: context.read(), - adService: context.read(), - inlineAdCacheService: - context.read(), - )..add( - EntityDetailsLoadRequested( - entityId: args.entityId, - contentType: args.contentType, - adThemeStyle: adThemeStyle, + create: (context) => + EntityDetailsBloc( + headlinesRepository: context + .read>(), + topicRepository: context.read>(), + sourceRepository: context.read>(), + countryRepository: context + .read>(), + appBloc: context.read(), + adService: context.read(), + inlineAdCacheService: context + .read(), + )..add( + EntityDetailsLoadRequested( + entityId: args.entityId, + contentType: args.contentType, + adThemeStyle: adThemeStyle, + ), ), - ), ), ], child: EntityDetailsPage(args: args), @@ -501,7 +503,8 @@ GoRouter createRouter({ final allItems = extra['allItems'] as List? ?? []; final initialSelectedItems = extra['initialSelectedItems'] as Set? ?? {}; - final itemBuilder = extra['itemBuilder'] as Function? ?? + final itemBuilder = + extra['itemBuilder'] as Function? ?? (dynamic item) => item.toString(); return MultiSelectSearchPage( @@ -535,13 +538,13 @@ GoRouter createRouter({ final initialUserContentPreferences = appBloc.state.userContentPreferences; return HeadlinesFeedBloc( - headlinesRepository: - context.read>(), + headlinesRepository: context + .read>(), feedDecoratorService: FeedDecoratorService(), adService: context.read(), appBloc: appBloc, - inlineAdCacheService: - context.read(), + inlineAdCacheService: context + .read(), feedCacheService: context.read(), initialUserContentPreferences: initialUserContentPreferences, @@ -562,8 +565,9 @@ GoRouter createRouter({ name: Routes.articleDetailsName, builder: (context, state) { final extra = state.extra; - final headlineFromExtra = - extra is Headline ? extra : null; + final headlineFromExtra = extra is Headline + ? extra + : null; final headlineIdFromPath = state.pathParameters['id']; final notificationId = extra is Map ? extra['notificationId'] as String? @@ -573,14 +577,14 @@ GoRouter createRouter({ providers: [ BlocProvider( create: (context) => HeadlineDetailsBloc( - headlinesRepository: - context.read>(), + headlinesRepository: context + .read>(), ), ), BlocProvider( create: (context) => SimilarHeadlinesBloc( - headlinesRepository: - context.read>(), + headlinesRepository: context + .read>(), ), ), ], @@ -653,22 +657,22 @@ GoRouter createRouter({ builder: (context, state) { final extra = state.extra as Map? ?? - {}; + {}; final allCountries = extra['allCountries'] as List? ?? - []; + []; final allSourceTypes = extra['allSourceTypes'] - as List? ?? - []; + as List? ?? + []; final initialSelectedHeadquarterCountries = extra['initialSelectedHeadquarterCountries'] - as Set? ?? - {}; + as Set? ?? + {}; final initialSelectedSourceTypes = extra['initialSelectedSourceTypes'] - as Set? ?? - {}; + as Set? ?? + {}; return feed_filter.SourceListFilterPage( allCountries: allCountries,