Skip to content

Commit 536991d

Browse files
authored
Merge pull request #213 from flutter-news-app-full-source-code/feat/handle-push-notification-reception
Feat/handle push notification reception
2 parents decbbd5 + a484da3 commit 536991d

28 files changed

+1384
-120
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ unlinked_spec.ds
5252
**/android/app/debug
5353
**/android/app/profile
5454
**/android/app/release
55+
**/android/app/google-services.json
56+
5557
*.jks
5658

5759
# iOS/XCode related

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,21 @@ A beautiful, infinitely scrolling feed serves as the core of the user experience
3838
### 🔎 Powerful Content Curation & Discovery
3939
Give users the tools to find exactly what they're looking for with a multi-faceted discovery system.
4040
- **Customizable Filter Bar:** A persistent, one-tap filter bar provides instant access to pre-defined and user-created content streams.
41-
- **Advanced Content Curation:** A dedicated management UI allows users to construct, save, and manage highly specific news feeds. Users can save frequently used filters for on-demand use and pin them for one-tap access on the main feed.
41+
- **Advanced Content Curation:** A dedicated saved filter hub allows users to construct, save, and manage highly specific news feeds. Users can save frequently used filters for on-demand use and pin them for one-tap access on the main feed.
4242
- **Proactive Notification Subscriptions:** When saving a filter, users can subscribe to receive push notifications—such as breaking news alerts or daily digests—for content that matches their specific criteria.
4343
- **Dedicated Discovery Hub:** Users can browse publishers by category in horizontally scrolling carousels, apply regional filters, and perform targeted searches.
4444
> **Your Advantage:** Deliver powerful content discovery tools that keep users engaged, increase session duration, and encourage return visits.
4545
46+
---
47+
48+
### 🔔 Proactive & Flexible Push Notifications
49+
A robust, backend-driven notification system keeps users informed and brings them back to the content they care about.
50+
- **Multi-Provider Architecture:** Built on an abstraction that supports any push notification service. It ships with production-ready providers for Firebase (FCM) and OneSignal.
51+
- **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.
52+
- **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.
53+
- **Foreground Notification Handling:** Displays a subtle in-app indicator when a notification arrives while the user is active, avoiding intrusive alerts.
54+
> **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.
55+
4656
</details>
4757

4858
<details>

android/app/google-services.json

Lines changed: 0 additions & 29 deletions
This file was deleted.

lib/app/bloc/app_bloc.dart

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'package:flutter_news_app_mobile_client_full_source_code/ads/services/inl
1111
import 'package:flutter_news_app_mobile_client_full_source_code/app/models/app_life_cycle_status.dart';
1212
import 'package:flutter_news_app_mobile_client_full_source_code/app/models/initialization_result.dart';
1313
import 'package:flutter_news_app_mobile_client_full_source_code/app/services/app_initializer.dart';
14+
import 'package:flutter_news_app_mobile_client_full_source_code/notifications/services/push_notification_service.dart';
1415
import 'package:flutter_news_app_mobile_client_full_source_code/shared/extensions/extensions.dart';
1516
import 'package:logging/logging.dart';
1617

@@ -45,12 +46,14 @@ class AppBloc extends Bloc<AppEvent, AppState> {
4546
required InlineAdCacheService inlineAdCacheService,
4647
required Logger logger,
4748
required DataRepository<User> userRepository,
49+
required PushNotificationService pushNotificationService,
4850
}) : _remoteConfigRepository = remoteConfigRepository,
4951
_appInitializer = appInitializer,
5052
_authRepository = authRepository,
5153
_userAppSettingsRepository = userAppSettingsRepository,
5254
_userContentPreferencesRepository = userContentPreferencesRepository,
5355
_userRepository = userRepository,
56+
_pushNotificationService = pushNotificationService,
5457
_inlineAdCacheService = inlineAdCacheService,
5558
_logger = logger,
5659
super(
@@ -79,7 +82,19 @@ class AppBloc extends Bloc<AppEvent, AppState> {
7982
on<SavedHeadlineFilterUpdated>(_onSavedHeadlineFilterUpdated);
8083
on<SavedHeadlineFilterDeleted>(_onSavedHeadlineFilterDeleted);
8184
on<SavedHeadlineFiltersReordered>(_onSavedHeadlineFiltersReordered);
85+
on<AppPushNotificationDeviceRegistered>(
86+
_onAppPushNotificationDeviceRegistered,
87+
);
88+
on<AppInAppNotificationReceived>(_onAppInAppNotificationReceived);
8289
on<AppLogoutRequested>(_onLogoutRequested);
90+
on<AppPushNotificationTokenRefreshed>(_onAppPushNotificationTokenRefreshed);
91+
92+
// Listen to token refresh events from the push notification service.
93+
// When a token is refreshed, dispatch an event to trigger device
94+
// re-registration with the backend.
95+
_pushNotificationService.onTokenRefreshed.listen((_) {
96+
add(const AppPushNotificationTokenRefreshed());
97+
});
8398
}
8499

85100
final Logger _logger;
@@ -90,6 +105,7 @@ class AppBloc extends Bloc<AppEvent, AppState> {
90105
final DataRepository<UserContentPreferences>
91106
_userContentPreferencesRepository;
92107
final DataRepository<User> _userRepository;
108+
final PushNotificationService _pushNotificationService;
93109
final InlineAdCacheService _inlineAdCacheService;
94110

95111
/// Handles the [AppStarted] event.
@@ -101,6 +117,12 @@ class AppBloc extends Bloc<AppEvent, AppState> {
101117
_logger.fine(
102118
'[AppBloc] AppStarted event received. State is already initialized.',
103119
);
120+
121+
// If a user is already logged in when the app starts, register their
122+
// device for push notifications.
123+
if (state.user != null) {
124+
await _registerDeviceForPushNotifications(state.user!.id);
125+
}
104126
}
105127

106128
/// Handles all logic related to user authentication state changes.
@@ -210,6 +232,11 @@ class AppBloc extends Bloc<AppEvent, AppState> {
210232
clearError: true,
211233
),
212234
);
235+
236+
// After any successful user transition (including guest), attempt to
237+
// register their device for push notifications. This ensures that
238+
// guests can also receive notifications.
239+
await _registerDeviceForPushNotifications(user.id);
213240
// If the transition fails (e.g., due to a network error while
214241
// fetching user data), emit a critical error state.
215242
case InitializationFailure(:final status, :final error):
@@ -576,4 +603,71 @@ class AppBloc extends Bloc<AppEvent, AppState> {
576603
'with reordered filters.',
577604
);
578605
}
606+
607+
/// Handles the [AppPushNotificationDeviceRegistered] event.
608+
///
609+
/// This handler is primarily for logging and does not change the state.
610+
void _onAppPushNotificationDeviceRegistered(
611+
AppPushNotificationDeviceRegistered event,
612+
Emitter<AppState> emit,
613+
) {
614+
_logger.info('[AppBloc] Push notification device registration noted.');
615+
}
616+
617+
/// Handles the [AppInAppNotificationReceived] event.
618+
///
619+
/// This handler updates the state to indicate that a new, unread in-app
620+
/// notification has been received, which can be used to show a UI indicator.
621+
void _onAppInAppNotificationReceived(
622+
AppInAppNotificationReceived event,
623+
Emitter<AppState> emit,
624+
) {
625+
emit(state.copyWith(hasUnreadInAppNotifications: true));
626+
}
627+
628+
/// Handles the [AppPushNotificationTokenRefreshed] event.
629+
///
630+
/// This event is triggered when the underlying push notification provider
631+
/// (e.g., FCM, OneSignal) refreshes its device token. The AppBloc then
632+
/// attempts to re-register the device with the backend using the current
633+
/// user's ID.
634+
Future<void> _onAppPushNotificationTokenRefreshed(
635+
AppPushNotificationTokenRefreshed event,
636+
Emitter<AppState> emit,
637+
) async {
638+
if (state.user == null) {
639+
_logger.info('[AppBloc] Skipping token re-registration: User is null.');
640+
return;
641+
}
642+
_logger.info(
643+
'[AppBloc] Push notification token refreshed. Re-registering device.',
644+
);
645+
await _registerDeviceForPushNotifications(state.user!.id);
646+
}
647+
648+
/// A private helper method to encapsulate the logic for registering a
649+
/// device for push notifications.
650+
///
651+
/// This method is called from multiple places (`_onAppStarted`,
652+
/// `_onAppUserChanged`, `_onAppPushNotificationTokenRefreshed`) to avoid
653+
/// code duplication. It includes robust error handling to prevent unhandled
654+
/// exceptions from crashing the BLoC.
655+
Future<void> _registerDeviceForPushNotifications(String userId) async {
656+
_logger.info(
657+
'[AppBloc] Attempting to register device for push notifications for user $userId.',
658+
);
659+
try {
660+
// The PushNotificationService handles getting the token and calling the
661+
// repository's create method. The `registerDevice` method implements a
662+
// "delete-then-create" pattern for idempotency.
663+
await _pushNotificationService.registerDevice(userId: userId);
664+
add(const AppPushNotificationDeviceRegistered());
665+
} catch (e, s) {
666+
_logger.severe(
667+
'[AppBloc] Failed to register push notification device for user $userId.',
668+
e,
669+
s,
670+
);
671+
}
672+
}
579673
}

lib/app/bloc/app_event.dart

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,36 @@ class SavedHeadlineFiltersReordered extends AppEvent {
184184
@override
185185
List<Object> get props => [reorderedFilters];
186186
}
187+
188+
/// {@template app_push_notification_device_registered}
189+
/// Dispatched when a push notification device has been successfully registered
190+
/// with the backend.
191+
///
192+
/// This event is for logging and potential future state changes, but does not
193+
/// directly alter the UI state in the current implementation.
194+
/// {@endtemplate}
195+
class AppPushNotificationDeviceRegistered extends AppEvent {
196+
/// {@macro app_push_notification_device_registered}
197+
const AppPushNotificationDeviceRegistered();
198+
}
199+
200+
/// {@template app_in_app_notification_received}
201+
/// Dispatched when a push notification is received while the app is in the
202+
/// foreground, used to show an unread indicator.
203+
/// {@endtemplate}
204+
class AppInAppNotificationReceived extends AppEvent {
205+
/// {@macro app_in_app_notification_received}
206+
const AppInAppNotificationReceived();
207+
}
208+
209+
/// {@template app_push_notification_token_refreshed}
210+
/// Dispatched when the underlying push notification provider refreshes its
211+
/// device token.
212+
///
213+
/// This event triggers the AppBloc to re-register the device with the backend
214+
/// using the current user's ID.
215+
/// {@endtemplate}
216+
class AppPushNotificationTokenRefreshed extends AppEvent {
217+
/// {@macro app_push_notification_token_refreshed}
218+
const AppPushNotificationTokenRefreshed();
219+
}

lib/app/bloc/app_state.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class AppState extends Equatable {
1919
this.selectedBottomNavigationIndex = 0,
2020
this.currentAppVersion,
2121
this.latestAppVersion,
22+
this.hasUnreadInAppNotifications = false,
2223
});
2324

2425
/// The current status of the application, indicating its lifecycle stage.
@@ -55,6 +56,9 @@ class AppState extends Equatable {
5556
/// The latest required app version, passed from [InitializationFailure].
5657
final String? latestAppVersion;
5758

59+
/// A flag indicating if there are unread in-app notifications.
60+
final bool hasUnreadInAppNotifications;
61+
5862
/// The current theme mode (light, dark, or system), derived from [settings].
5963
/// Defaults to [ThemeMode.system] if [settings] are not yet loaded.
6064
ThemeMode get themeMode {
@@ -123,6 +127,7 @@ class AppState extends Equatable {
123127
selectedBottomNavigationIndex,
124128
currentAppVersion,
125129
latestAppVersion,
130+
hasUnreadInAppNotifications,
126131
];
127132

128133
/// Creates a copy of this [AppState] with the given fields replaced with
@@ -139,6 +144,7 @@ class AppState extends Equatable {
139144
int? selectedBottomNavigationIndex,
140145
String? currentAppVersion,
141146
String? latestAppVersion,
147+
bool? hasUnreadInAppNotifications,
142148
}) {
143149
return AppState(
144150
status: status ?? this.status,
@@ -153,6 +159,8 @@ class AppState extends Equatable {
153159
selectedBottomNavigationIndex ?? this.selectedBottomNavigationIndex,
154160
currentAppVersion: currentAppVersion ?? this.currentAppVersion,
155161
latestAppVersion: latestAppVersion ?? this.latestAppVersion,
162+
hasUnreadInAppNotifications:
163+
hasUnreadInAppNotifications ?? this.hasUnreadInAppNotifications,
156164
);
157165
}
158166
}

0 commit comments

Comments
 (0)