Skip to content

Commit bde96de

Browse files
authored
Merge pull request #214 from flutter-news-app-full-source-code/feat/in-app-notification-center
Feat/in app notification center
2 parents 536991d + 13c85cc commit bde96de

27 files changed

+1281
-77
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ A robust, backend-driven notification system keeps users informed and brings the
5050
- **Multi-Provider Architecture:** Built on an abstraction that supports any push notification service. It ships with production-ready providers for Firebase (FCM) and OneSignal.
5151
- **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.
5252
- **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.
53+
- **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.
5454
> **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.
5555
5656
</details>
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import 'dart:async';
2+
3+
import 'package:bloc/bloc.dart';
4+
import 'package:collection/collection.dart';
5+
import 'package:core/core.dart';
6+
import 'package:data_repository/data_repository.dart';
7+
import 'package:equatable/equatable.dart';
8+
import 'package:flutter_news_app_mobile_client_full_source_code/app/bloc/app_bloc.dart';
9+
import 'package:logging/logging.dart';
10+
11+
part 'in_app_notification_center_event.dart';
12+
part 'in_app_notification_center_state.dart';
13+
14+
/// {@template in_app_notification_center_bloc}
15+
/// Manages the state for the in-app notification center.
16+
///
17+
/// This BLoC is responsible for fetching the user's notifications,
18+
/// handling actions to mark them as read individually or in bulk, and
19+
/// coordinating with the global [AppBloc] to update the unread status
20+
/// indicator across the app.
21+
/// {@endtemplate}
22+
class InAppNotificationCenterBloc
23+
extends Bloc<InAppNotificationCenterEvent, InAppNotificationCenterState> {
24+
/// {@macro in_app_notification_center_bloc}
25+
InAppNotificationCenterBloc({
26+
required DataRepository<InAppNotification> inAppNotificationRepository,
27+
required AppBloc appBloc,
28+
required Logger logger,
29+
}) : _inAppNotificationRepository = inAppNotificationRepository,
30+
_appBloc = appBloc,
31+
_logger = logger,
32+
super(const InAppNotificationCenterState()) {
33+
on<InAppNotificationCenterSubscriptionRequested>(_onSubscriptionRequested);
34+
on<InAppNotificationCenterMarkedAsRead>(_onMarkedAsRead);
35+
on<InAppNotificationCenterMarkAllAsRead>(_onMarkAllAsRead);
36+
on<InAppNotificationCenterTabChanged>(_onTabChanged);
37+
on<InAppNotificationCenterMarkOneAsRead>(_onMarkOneAsRead);
38+
}
39+
40+
final DataRepository<InAppNotification> _inAppNotificationRepository;
41+
final AppBloc _appBloc;
42+
final Logger _logger;
43+
44+
/// Handles the request to load all notifications for the current user.
45+
Future<void> _onSubscriptionRequested(
46+
InAppNotificationCenterSubscriptionRequested event,
47+
Emitter<InAppNotificationCenterState> emit,
48+
) async {
49+
emit(state.copyWith(status: InAppNotificationCenterStatus.loading));
50+
51+
final userId = _appBloc.state.user?.id;
52+
if (userId == null) {
53+
_logger.warning('Cannot fetch notifications: user is not logged in.');
54+
emit(state.copyWith(status: InAppNotificationCenterStatus.failure));
55+
return;
56+
}
57+
58+
try {
59+
final response = await _inAppNotificationRepository.readAll(
60+
userId: userId,
61+
sort: [const SortOption('createdAt', SortOrder.desc)],
62+
);
63+
64+
final allNotifications = response.items;
65+
66+
final breakingNews = <InAppNotification>[];
67+
final digests = <InAppNotification>[];
68+
69+
// Filter notifications into their respective categories, prioritizing
70+
// 'notificationType' from the backend, then falling back to 'contentType'.
71+
for (final n in allNotifications) {
72+
final notificationType = n.payload.data['notificationType'] as String?;
73+
final contentType = n.payload.data['contentType'] as String?;
74+
75+
if (notificationType ==
76+
PushNotificationSubscriptionDeliveryType.dailyDigest.name ||
77+
notificationType ==
78+
PushNotificationSubscriptionDeliveryType.weeklyRoundup.name ||
79+
contentType == 'digest') {
80+
digests.add(n);
81+
} else {
82+
// All other types (including 'breakingOnly' notificationType,
83+
// 'headline' contentType, or any unknown types) go to breaking news.
84+
breakingNews.add(n);
85+
}
86+
}
87+
88+
emit(
89+
state.copyWith(
90+
status: InAppNotificationCenterStatus.success,
91+
breakingNewsNotifications: breakingNews,
92+
digestNotifications: digests,
93+
),
94+
);
95+
} on HttpException catch (e, s) {
96+
_logger.severe('Failed to fetch in-app notifications.', e, s);
97+
emit(
98+
state.copyWith(status: InAppNotificationCenterStatus.failure, error: e),
99+
);
100+
} catch (e, s) {
101+
_logger.severe(
102+
'An unexpected error occurred while fetching in-app notifications.',
103+
e,
104+
s,
105+
);
106+
emit(
107+
state.copyWith(
108+
status: InAppNotificationCenterStatus.failure,
109+
error: UnknownException(e.toString()),
110+
),
111+
);
112+
}
113+
}
114+
115+
/// Handles the event to change the active tab.
116+
Future<void> _onTabChanged(
117+
InAppNotificationCenterTabChanged event,
118+
Emitter<InAppNotificationCenterState> emit,
119+
) async {
120+
emit(state.copyWith(currentTabIndex: event.tabIndex));
121+
}
122+
123+
/// Handles marking a single notification as read.
124+
Future<void> _onMarkedAsRead(
125+
InAppNotificationCenterMarkedAsRead event,
126+
Emitter<InAppNotificationCenterState> emit,
127+
) async {
128+
final notification = state.notifications.firstWhereOrNull(
129+
(n) => n.id == event.notificationId,
130+
);
131+
132+
await _markOneAsRead(notification, emit);
133+
}
134+
135+
/// Handles marking a single notification as read from a deep-link.
136+
Future<void> _onMarkOneAsRead(
137+
InAppNotificationCenterMarkOneAsRead event,
138+
Emitter<InAppNotificationCenterState> emit,
139+
) async {
140+
final notification = state.notifications.firstWhereOrNull(
141+
(n) => n.id == event.notificationId,
142+
);
143+
144+
if (notification == null) {
145+
_logger.warning(
146+
'Attempted to mark a notification as read that does not exist in the '
147+
'current state: ${event.notificationId}',
148+
);
149+
return;
150+
}
151+
152+
// If already read, do nothing.
153+
if (notification.isRead) return;
154+
155+
await _markOneAsRead(notification, emit);
156+
}
157+
158+
/// A shared helper method to mark a single notification as read.
159+
///
160+
/// This is used by both [_onMarkedAsRead] (from the notification center UI)
161+
/// and [_onMarkOneAsRead] (from a deep-link).
162+
Future<void> _markOneAsRead(
163+
InAppNotification? notification,
164+
Emitter<InAppNotificationCenterState> emit,
165+
) async {
166+
if (notification == null) return;
167+
final updatedNotification = notification.copyWith(readAt: DateTime.now());
168+
169+
try {
170+
await _inAppNotificationRepository.update(
171+
id: notification.id,
172+
item: updatedNotification,
173+
userId: _appBloc.state.user?.id,
174+
);
175+
176+
// Update the local state to reflect the change immediately.
177+
final updatedBreakingNewsList = state.breakingNewsNotifications
178+
.map((n) => n.id == notification.id ? updatedNotification : n)
179+
.toList();
180+
181+
final updatedDigestList = state.digestNotifications
182+
.map((n) => n.id == notification.id ? updatedNotification : n)
183+
.toList();
184+
185+
emit(
186+
state.copyWith(
187+
breakingNewsNotifications: updatedBreakingNewsList,
188+
digestNotifications: updatedDigestList,
189+
),
190+
);
191+
192+
// Notify the global AppBloc to re-check the unread count.
193+
_appBloc.add(const AppInAppNotificationMarkedAsRead());
194+
} on HttpException catch (e, s) {
195+
_logger.severe(
196+
'Failed to mark notification ${notification.id} as read.',
197+
e,
198+
s,
199+
);
200+
emit(
201+
state.copyWith(status: InAppNotificationCenterStatus.failure, error: e),
202+
);
203+
// Do not revert state to avoid UI flicker. The error is logged.
204+
} catch (e, s) {
205+
_logger.severe(
206+
'An unexpected error occurred while marking notification as read.',
207+
e,
208+
s,
209+
);
210+
emit(
211+
state.copyWith(
212+
status: InAppNotificationCenterStatus.failure,
213+
error: UnknownException(e.toString()),
214+
),
215+
);
216+
}
217+
}
218+
219+
/// Handles marking all unread notifications as read.
220+
Future<void> _onMarkAllAsRead(
221+
InAppNotificationCenterMarkAllAsRead event,
222+
Emitter<InAppNotificationCenterState> emit,
223+
) async {
224+
final unreadNotifications = state.notifications
225+
.where((n) => !n.isRead)
226+
.toList();
227+
228+
if (unreadNotifications.isEmpty) return;
229+
230+
final now = DateTime.now();
231+
final updatedNotifications = unreadNotifications
232+
.map((n) => n.copyWith(readAt: now))
233+
.toList();
234+
235+
try {
236+
// Perform all updates in parallel.
237+
await Future.wait(
238+
updatedNotifications.map(
239+
(n) => _inAppNotificationRepository.update(
240+
id: n.id,
241+
item: n,
242+
userId: _appBloc.state.user?.id,
243+
),
244+
),
245+
);
246+
247+
// Update local state with all notifications marked as read.
248+
final fullyUpdatedBreakingNewsList = state.breakingNewsNotifications
249+
.map((n) => n.isRead ? n : n.copyWith(readAt: now))
250+
.toList();
251+
252+
final fullyUpdatedDigestList = state.digestNotifications
253+
.map((n) => n.isRead ? n : n.copyWith(readAt: now))
254+
.toList();
255+
emit(
256+
state.copyWith(
257+
breakingNewsNotifications: fullyUpdatedBreakingNewsList,
258+
digestNotifications: fullyUpdatedDigestList,
259+
),
260+
);
261+
262+
// Notify the global AppBloc to clear the unread indicator.
263+
_appBloc.add(const AppAllInAppNotificationsMarkedAsRead());
264+
} on HttpException catch (e, s) {
265+
_logger.severe('Failed to mark all notifications as read.', e, s);
266+
emit(
267+
state.copyWith(status: InAppNotificationCenterStatus.failure, error: e),
268+
);
269+
} catch (e, s) {
270+
_logger.severe(
271+
'An unexpected error occurred while marking all notifications as read.',
272+
e,
273+
s,
274+
);
275+
emit(
276+
state.copyWith(
277+
status: InAppNotificationCenterStatus.failure,
278+
error: UnknownException(e.toString()),
279+
),
280+
);
281+
}
282+
}
283+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
part of 'in_app_notification_center_bloc.dart';
2+
3+
/// Base class for all events in the [InAppNotificationCenterBloc].
4+
abstract class InAppNotificationCenterEvent extends Equatable {
5+
const InAppNotificationCenterEvent();
6+
7+
@override
8+
List<Object> get props => [];
9+
}
10+
11+
/// Dispatched when the notification center is opened and needs to load
12+
/// the initial list of notifications.
13+
class InAppNotificationCenterSubscriptionRequested
14+
extends InAppNotificationCenterEvent {
15+
const InAppNotificationCenterSubscriptionRequested();
16+
}
17+
18+
/// Dispatched when a single in-app notification is marked as read.
19+
class InAppNotificationCenterMarkedAsRead extends InAppNotificationCenterEvent {
20+
const InAppNotificationCenterMarkedAsRead(this.notificationId);
21+
22+
/// The ID of the notification to be marked as read.
23+
final String notificationId;
24+
25+
@override
26+
List<Object> get props => [notificationId];
27+
}
28+
29+
/// Dispatched when the user requests to mark all notifications as read.
30+
class InAppNotificationCenterMarkAllAsRead
31+
extends InAppNotificationCenterEvent {
32+
const InAppNotificationCenterMarkAllAsRead();
33+
}
34+
35+
/// Dispatched when the user changes the selected tab in the notification center.
36+
class InAppNotificationCenterTabChanged extends InAppNotificationCenterEvent {
37+
const InAppNotificationCenterTabChanged(this.tabIndex);
38+
39+
/// The index of the newly selected tab. 0: Breaking News, 1: Digests.
40+
final int tabIndex;
41+
42+
@override
43+
List<Object> get props => [tabIndex];
44+
}
45+
46+
/// Dispatched when a single in-app notification is marked as read by its ID,
47+
/// typically from a deep-link without navigating from the notification center.
48+
class InAppNotificationCenterMarkOneAsRead
49+
extends InAppNotificationCenterEvent {
50+
const InAppNotificationCenterMarkOneAsRead(this.notificationId);
51+
52+
/// The ID of the notification to be marked as read.
53+
final String notificationId;
54+
55+
@override
56+
List<Object> get props => [notificationId];
57+
}

0 commit comments

Comments
 (0)