Skip to content

Commit 2d5b61c

Browse files
authored
Merge pull request #217 from flutter-news-app-full-source-code/feat/In-App-Notification-Center-Infinite-Scrolling
Feat/in app notification center infinite scrolling
2 parents 838ac18 + e29baf2 commit 2d5b61c

File tree

6 files changed

+288
-60
lines changed

6 files changed

+288
-60
lines changed

lib/account/bloc/in_app_notification_center_bloc.dart

Lines changed: 165 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:async';
22

33
import 'package:bloc/bloc.dart';
4+
import 'package:bloc_concurrency/bloc_concurrency.dart';
45
import 'package:collection/collection.dart';
56
import 'package:core/core.dart';
67
import 'package:data_repository/data_repository.dart';
@@ -30,85 +31,135 @@ class InAppNotificationCenterBloc
3031
_appBloc = appBloc,
3132
_logger = logger,
3233
super(const InAppNotificationCenterState()) {
33-
on<InAppNotificationCenterSubscriptionRequested>(_onSubscriptionRequested);
34+
on<InAppNotificationCenterSubscriptionRequested>(
35+
_onSubscriptionRequested,
36+
transformer: droppable(),
37+
);
3438
on<InAppNotificationCenterMarkedAsRead>(_onMarkedAsRead);
3539
on<InAppNotificationCenterMarkAllAsRead>(_onMarkAllAsRead);
3640
on<InAppNotificationCenterTabChanged>(_onTabChanged);
3741
on<InAppNotificationCenterMarkOneAsRead>(_onMarkOneAsRead);
42+
on<InAppNotificationCenterFetchMoreRequested>(
43+
_onFetchMoreRequested,
44+
transformer: droppable(),
45+
);
3846
}
3947

48+
/// The number of notifications to fetch per page.
49+
static const _notificationsFetchLimit = 10;
50+
4051
final DataRepository<InAppNotification> _inAppNotificationRepository;
4152
final AppBloc _appBloc;
4253
final Logger _logger;
4354

44-
/// Handles the request to load all notifications for the current user.
55+
/// Handles the initial subscription request to fetch notifications for both
56+
/// tabs concurrently.
4557
Future<void> _onSubscriptionRequested(
4658
InAppNotificationCenterSubscriptionRequested event,
4759
Emitter<InAppNotificationCenterState> emit,
4860
) async {
4961
emit(state.copyWith(status: InAppNotificationCenterStatus.loading));
50-
5162
final userId = _appBloc.state.user?.id;
5263
if (userId == null) {
53-
_logger.warning('Cannot fetch notifications: user is not logged in.');
64+
_logger.warning(
65+
'Cannot fetch more notifications: user is not logged in.',
66+
);
5467
emit(state.copyWith(status: InAppNotificationCenterStatus.failure));
5568
return;
5669
}
5770

5871
try {
59-
final response = await _inAppNotificationRepository.readAll(
60-
userId: userId,
61-
sort: [const SortOption('createdAt', SortOrder.desc)],
62-
);
72+
// Fetch both tabs' initial data in parallel and wait for their results.
73+
final results = await Future.wait([
74+
_fetchNotifications(userId: userId, filter: _breakingNewsFilter),
75+
_fetchNotifications(userId: userId, filter: _digestFilter),
76+
]);
6377

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-
}
78+
final breakingNewsResponse = results[0];
79+
final digestResponse = results[1];
8780

81+
// Perform a single, atomic state update with both results.
8882
emit(
8983
state.copyWith(
9084
status: InAppNotificationCenterStatus.success,
91-
breakingNewsNotifications: breakingNews,
92-
digestNotifications: digests,
85+
breakingNewsNotifications: breakingNewsResponse.items,
86+
breakingNewsHasMore: breakingNewsResponse.hasMore,
87+
breakingNewsCursor: breakingNewsResponse.cursor,
88+
digestNotifications: digestResponse.items,
89+
digestHasMore: digestResponse.hasMore,
90+
digestCursor: digestResponse.cursor,
9391
),
9492
);
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,
93+
} catch (error, stackTrace) {
94+
_handleFetchError(emit, error, stackTrace);
95+
}
96+
}
97+
98+
/// Handles fetching the next page of notifications for the current tab.
99+
Future<void> _onFetchMoreRequested(
100+
InAppNotificationCenterFetchMoreRequested event,
101+
Emitter<InAppNotificationCenterState> emit,
102+
) async {
103+
final isBreakingNewsTab = state.currentTabIndex == 0;
104+
final hasMore = isBreakingNewsTab
105+
? state.breakingNewsHasMore
106+
: state.digestHasMore;
107+
108+
if (state.status == InAppNotificationCenterStatus.loadingMore || !hasMore) {
109+
return;
110+
}
111+
112+
emit(state.copyWith(status: InAppNotificationCenterStatus.loadingMore));
113+
114+
final userId = _appBloc.state.user?.id;
115+
if (userId == null) {
116+
_logger.warning(
117+
'Cannot fetch more notifications: user is not logged in.',
105118
);
106-
emit(
107-
state.copyWith(
108-
status: InAppNotificationCenterStatus.failure,
109-
error: UnknownException(e.toString()),
110-
),
119+
emit(state.copyWith(status: InAppNotificationCenterStatus.failure));
120+
return;
121+
}
122+
123+
final filter = isBreakingNewsTab ? _breakingNewsFilter : _digestFilter;
124+
final cursor = isBreakingNewsTab
125+
? state.breakingNewsCursor
126+
: state.digestCursor;
127+
128+
try {
129+
final response = await _fetchNotifications(
130+
userId: userId,
131+
filter: filter,
132+
cursor: cursor,
111133
);
134+
135+
// Append the new items to the correct list.
136+
if (isBreakingNewsTab) {
137+
emit(
138+
state.copyWith(
139+
status: InAppNotificationCenterStatus.success,
140+
breakingNewsNotifications: [
141+
...state.breakingNewsNotifications,
142+
...response.items,
143+
],
144+
breakingNewsHasMore: response.hasMore,
145+
breakingNewsCursor: response.cursor,
146+
),
147+
);
148+
} else {
149+
emit(
150+
state.copyWith(
151+
status: InAppNotificationCenterStatus.success,
152+
digestNotifications: [
153+
...state.digestNotifications,
154+
...response.items,
155+
],
156+
digestHasMore: response.hasMore,
157+
digestCursor: response.cursor,
158+
),
159+
);
160+
}
161+
} catch (error, stackTrace) {
162+
_handleFetchError(emit, error, stackTrace);
112163
}
113164
}
114165

@@ -117,6 +168,8 @@ class InAppNotificationCenterBloc
117168
InAppNotificationCenterTabChanged event,
118169
Emitter<InAppNotificationCenterState> emit,
119170
) async {
171+
// If the tab is changed, we don't need to re-fetch data as it was
172+
// already fetched on initial load. We just update the index.
120173
emit(state.copyWith(currentTabIndex: event.tabIndex));
121174
}
122175

@@ -280,4 +333,68 @@ class InAppNotificationCenterBloc
280333
);
281334
}
282335
}
336+
337+
/// A generic method to fetch notifications based on a filter.
338+
Future<PaginatedResponse<InAppNotification>> _fetchNotifications({
339+
required String userId,
340+
required Map<String, dynamic> filter,
341+
String? cursor,
342+
}) async {
343+
// This method now simply fetches and returns the data, or throws on error.
344+
// The responsibility of emitting state is moved to the event handlers.
345+
return _inAppNotificationRepository.readAll(
346+
userId: userId,
347+
filter: filter,
348+
pagination: PaginationOptions(
349+
limit: _notificationsFetchLimit,
350+
cursor: cursor,
351+
),
352+
sort: [const SortOption('createdAt', SortOrder.desc)],
353+
);
354+
}
355+
356+
/// Filter for "Breaking News" notifications.
357+
///
358+
/// This filter uses the `$nin` (not in) operator to exclude notifications
359+
/// that are explicitly typed as digests. All other notifications are
360+
/// considered "breaking news" for the purpose of this tab.
361+
Map<String, dynamic> get _breakingNewsFilter => {
362+
'payload.data.notificationType': {
363+
r'$nin': [
364+
PushNotificationSubscriptionDeliveryType.dailyDigest.name,
365+
PushNotificationSubscriptionDeliveryType.weeklyRoundup.name,
366+
],
367+
},
368+
};
369+
370+
/// Filter for "Digests" notifications.
371+
///
372+
/// This filter uses the `$in` operator to select notifications that are
373+
/// explicitly typed as either a daily or weekly digest.
374+
Map<String, dynamic> get _digestFilter => {
375+
'payload.data.notificationType': {
376+
r'$in': [
377+
PushNotificationSubscriptionDeliveryType.dailyDigest.name,
378+
PushNotificationSubscriptionDeliveryType.weeklyRoundup.name,
379+
],
380+
},
381+
};
382+
383+
/// Centralized error handler for fetch operations.
384+
void _handleFetchError(
385+
Emitter<InAppNotificationCenterState> emit,
386+
Object error,
387+
StackTrace stackTrace,
388+
) {
389+
_logger.severe('Failed to fetch notifications.', error, stackTrace);
390+
final httpException = error is HttpException
391+
? error
392+
: UnknownException(error.toString());
393+
emit(
394+
state.copyWith(
395+
status: InAppNotificationCenterStatus.failure,
396+
error: httpException,
397+
),
398+
);
399+
}
283400
}

lib/account/bloc/in_app_notification_center_event.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,10 @@ class InAppNotificationCenterMarkOneAsRead
5555
@override
5656
List<Object> get props => [notificationId];
5757
}
58+
59+
/// Dispatched when the user scrolls to the end of a notification list and
60+
/// more data needs to be fetched.
61+
class InAppNotificationCenterFetchMoreRequested
62+
extends InAppNotificationCenterEvent {
63+
const InAppNotificationCenterFetchMoreRequested();
64+
}

lib/account/bloc/in_app_notification_center_state.dart

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ enum InAppNotificationCenterStatus {
88
/// The state when notifications are being loaded.
99
loading,
1010

11+
/// The state when more notifications are being loaded for pagination.
12+
loadingMore,
13+
1114
/// The state when notifications have been successfully loaded.
1215
success,
1316

@@ -25,6 +28,10 @@ class InAppNotificationCenterState extends Equatable {
2528
this.breakingNewsNotifications = const [],
2629
this.digestNotifications = const [],
2730
this.currentTabIndex = 0,
31+
this.breakingNewsHasMore = true,
32+
this.breakingNewsCursor,
33+
this.digestHasMore = true,
34+
this.digestCursor,
2835
this.error,
2936
});
3037

@@ -50,12 +57,28 @@ class InAppNotificationCenterState extends Equatable {
5057
/// An error that occurred during notification loading or processing.
5158
final HttpException? error;
5259

60+
/// A flag indicating if there are more breaking news notifications to fetch.
61+
final bool breakingNewsHasMore;
62+
63+
/// The cursor for fetching the next page of breaking news notifications.
64+
final String? breakingNewsCursor;
65+
66+
/// A flag indicating if there are more digest notifications to fetch.
67+
final bool digestHasMore;
68+
69+
/// The cursor for fetching the next page of digest notifications.
70+
final String? digestCursor;
71+
5372
@override
5473
List<Object> get props => [
5574
status,
5675
currentTabIndex,
5776
breakingNewsNotifications,
5877
digestNotifications,
78+
breakingNewsHasMore,
79+
breakingNewsCursor ?? Object(),
80+
digestHasMore,
81+
digestCursor ?? Object(),
5982
error ?? Object(), // Include error in props, handle nullability
6083
];
6184

@@ -67,14 +90,29 @@ class InAppNotificationCenterState extends Equatable {
6790
int? currentTabIndex,
6891
List<InAppNotification>? breakingNewsNotifications,
6992
List<InAppNotification>? digestNotifications,
93+
bool? breakingNewsHasMore,
94+
// Use a nullable wrapper to explicitly set the cursor to null.
95+
Object? breakingNewsCursor,
96+
bool? digestHasMore,
97+
Object? digestCursor,
7098
}) {
7199
return InAppNotificationCenterState(
72100
status: status ?? this.status,
73-
error: error ?? this.error,
101+
// Allow explicitly setting the error to null.
102+
// ignore: avoid_redundant_argument_values
103+
error: error,
74104
currentTabIndex: currentTabIndex ?? this.currentTabIndex,
75105
breakingNewsNotifications:
76106
breakingNewsNotifications ?? this.breakingNewsNotifications,
77107
digestNotifications: digestNotifications ?? this.digestNotifications,
108+
breakingNewsHasMore: breakingNewsHasMore ?? this.breakingNewsHasMore,
109+
breakingNewsCursor: breakingNewsCursor == null
110+
? this.breakingNewsCursor
111+
: breakingNewsCursor as String?,
112+
digestHasMore: digestHasMore ?? this.digestHasMore,
113+
digestCursor: digestCursor == null
114+
? this.digestCursor
115+
: digestCursor as String?,
78116
);
79117
}
80118
}

0 commit comments

Comments
 (0)