Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
cf0ef5c
feat(l10n): add notification center translations
fulleni Nov 20, 2025
38ddef7
feat(push-notification): add in-app notification support
fulleni Nov 20, 2025
83a57d0
feat(app): add in-app notification repository to app initialization
fulleni Nov 20, 2025
558794a
feat(app): handle in-app notification read events
fulleni Nov 20, 2025
7bdb2d7
feat(app): inject in-app notification repository into App widget
fulleni Nov 20, 2025
292fc03
feat(router): add in-app notification center route
fulleni Nov 20, 2025
1d21317
feat(account): add notifications tile with indicator
fulleni Nov 20, 2025
032a842
feat(in-app-notification): implement BLoC for notification center
fulleni Nov 20, 2025
3fb38e9
feat(in-app notification center): add notification center page and li…
fulleni Nov 20, 2025
8076877
feat(notifications): add onInAppNotificationReceived stream to PushNo…
fulleni Nov 20, 2025
434c692
feat(notifications): persist push notifications as in-app notifications
fulleni Nov 20, 2025
609e812
feat(notifications): persist push notifications as in-app notifications
fulleni Nov 20, 2025
2648cd3
feat(notifications): add in-app notification stream to no-op service
fulleni Nov 20, 2025
a0777e5
style: change notification indicator color to primary
fulleni Nov 20, 2025
4b348fe
refactor(notifications): handle foreground push notifications in AppBloc
fulleni Nov 20, 2025
6537c32
refactor(push_notification): remove in-app notification repository
fulleni Nov 20, 2025
b5277ba
style: format
fulleni Nov 20, 2025
6c28926
chore: file relocate
fulleni Nov 20, 2025
9f7e048
fix(router): move notifications route inside account modal
fulleni Nov 20, 2025
91cc538
refactor(router): update notifications route and remove duplicate
fulleni Nov 20, 2025
2fb4f05
fix(account): update route name for notifications
fulleni Nov 20, 2025
21c5304
refactor(account): simplify notification center
fulleni Nov 20, 2025
74702ce
build(l10n): updated
fulleni Nov 20, 2025
82be099
fix(demo): initialize in-app notifications for new users
fulleni Nov 20, 2025
3f2a900
fix(account): update in-app notification filtering logic
fulleni Nov 20, 2025
110ab77
feat(notifications): simulate push notifications in demo mode
fulleni Nov 20, 2025
dde131d
style: format
fulleni Nov 20, 2025
c6d07e3
test(notifications): increase delay for in-app notifications in demo …
fulleni Nov 20, 2025
24d15d1
feat(README): update feature list and description
fulleni Nov 20, 2025
a417279
fix(account): handle null case for notification in markAsRead
fulleni Nov 20, 2025
0cf7337
refactor(app): replace Random ID generation with UUID
fulleni Nov 20, 2025
985e2bd
fix(account): move InAppNotificationCenterBlocProvider to router
fulleni Nov 20, 2025
48c59f0
refactor(account): improve notification filtering logic
fulleni Nov 20, 2025
35e5a8e
refactor(app): remove in-app notification persistence from client
fulleni Nov 20, 2025
5411896
feat(account): add support for marking a single notification as read …
fulleni Nov 20, 2025
934323e
feat(notification): handle notification payload in app routing
fulleni Nov 20, 2025
ee93b90
feat(headline-details): handle deep linking from notifications
fulleni Nov 20, 2025
f838424
feat(router): enhance navigation handling for deep links
fulleni Nov 20, 2025
9ef0b34
fix(notifications): simulate backend behavior in NoOp service
fulleni Nov 20, 2025
c34e26d
build(deps): update core dependency to b01f9b4
fulleni Nov 20, 2025
13c85cc
style: format
fulleni Nov 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

</details>
Expand Down
244 changes: 244 additions & 0 deletions lib/account/bloc/in_app_notification_center_bloc.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
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<InAppNotificationCenterEvent, InAppNotificationCenterState> {
/// {@macro in_app_notification_center_bloc}
InAppNotificationCenterBloc({
required DataRepository<InAppNotification> inAppNotificationRepository,
required AppBloc appBloc,
required Logger logger,
}) : _inAppNotificationRepository = inAppNotificationRepository,
_appBloc = appBloc,
_logger = logger,
super(const InAppNotificationCenterState()) {
on<InAppNotificationCenterSubscriptionRequested>(_onSubscriptionRequested);
on<InAppNotificationCenterMarkedAsRead>(_onMarkedAsRead);
on<InAppNotificationCenterMarkAllAsRead>(_onMarkAllAsRead);
on<InAppNotificationCenterTabChanged>(_onTabChanged);
}

final DataRepository<InAppNotification> _inAppNotificationRepository;
final AppBloc _appBloc;
final Logger _logger;

/// Handles the request to load all notifications for the current user.
Future<void> _onSubscriptionRequested(
InAppNotificationCenterSubscriptionRequested event,
Emitter<InAppNotificationCenterState> emit,
) async {
emit(state.copyWith(status: InAppNotificationCenterStatus.loading));

final userId = _appBloc.state.user?.id;
if (userId == null) {
_logger.warning('Cannot fetch notifications: user is not logged in.');
emit(state.copyWith(status: InAppNotificationCenterStatus.failure));
return;
}

try {
final response = await _inAppNotificationRepository.readAll(
userId: userId,
sort: [const SortOption('createdAt', SortOrder.desc)],
);

final allNotifications = response.items;

final breakingNews = <InAppNotification>[];
final digests = <InAppNotification>[];

// 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(
status: InAppNotificationCenterStatus.success,
breakingNewsNotifications: breakingNews,
digestNotifications: digests,
),
);
} on HttpException catch (e, s) {
_logger.severe('Failed to fetch in-app notifications.', e, s);
emit(
state.copyWith(status: InAppNotificationCenterStatus.failure, error: e),
);
} catch (e, s) {
_logger.severe(
'An unexpected error occurred while fetching in-app notifications.',
e,
s,
);
emit(
state.copyWith(
status: InAppNotificationCenterStatus.failure,
error: UnknownException(e.toString()),
),
);
}
}

/// Handles the event to change the active tab.
Future<void> _onTabChanged(
InAppNotificationCenterTabChanged event,
Emitter<InAppNotificationCenterState> emit,
) async {
emit(state.copyWith(currentTabIndex: event.tabIndex));
}

/// Handles marking a single notification as read.
Future<void> _onMarkedAsRead(
InAppNotificationCenterMarkedAsRead event,
Emitter<InAppNotificationCenterState> 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 updatedBreakingNewsList = state.breakingNewsNotifications
.map((n) => n.id == notification.id ? updatedNotification : n)
.toList();

final updatedDigestList = state.digestNotifications
.map((n) => n.id == notification.id ? updatedNotification : n)
.toList();

emit(
state.copyWith(
breakingNewsNotifications: updatedBreakingNewsList,
digestNotifications: updatedDigestList,
),
);

// Notify the global AppBloc to re-check the unread count.
_appBloc.add(const AppInAppNotificationMarkedAsRead());
} on HttpException catch (e, s) {
_logger.severe(
'Failed to mark notification ${notification.id} as read.',
e,
s,
);
emit(
state.copyWith(status: InAppNotificationCenterStatus.failure, error: e),
);
// Do not revert state to avoid UI flicker. The error is logged.
} catch (e, s) {
_logger.severe(
'An unexpected error occurred while marking notification as read.',
e,
s,
);
emit(
state.copyWith(
status: InAppNotificationCenterStatus.failure,
error: UnknownException(e.toString()),
),
);
}
}

/// Handles marking all unread notifications as read.
Future<void> _onMarkAllAsRead(
InAppNotificationCenterMarkAllAsRead event,
Emitter<InAppNotificationCenterState> emit,
) async {
final unreadNotifications = state.notifications
.where((n) => !n.isRead)
.toList();

if (unreadNotifications.isEmpty) return;

final now = DateTime.now();
final updatedNotifications = unreadNotifications
.map((n) => n.copyWith(readAt: now))
.toList();

try {
// Perform all updates in parallel.
await Future.wait(
updatedNotifications.map(
(n) => _inAppNotificationRepository.update(
id: n.id,
item: n,
userId: _appBloc.state.user?.id,
),
),
);

// Update local state with all notifications marked as read.
final fullyUpdatedBreakingNewsList = state.breakingNewsNotifications
.map((n) => n.isRead ? n : n.copyWith(readAt: now))
.toList();

final fullyUpdatedDigestList = state.digestNotifications
.map((n) => n.isRead ? n : n.copyWith(readAt: now))
.toList();
emit(
state.copyWith(
breakingNewsNotifications: fullyUpdatedBreakingNewsList,
digestNotifications: fullyUpdatedDigestList,
),
);

// Notify the global AppBloc to clear the unread indicator.
_appBloc.add(const AppAllInAppNotificationsMarkedAsRead());
} on HttpException catch (e, s) {
_logger.severe('Failed to mark all notifications as read.', e, s);
emit(
state.copyWith(status: InAppNotificationCenterStatus.failure, error: e),
);
} catch (e, s) {
_logger.severe(
'An unexpected error occurred while marking all notifications as read.',
e,
s,
);
emit(
state.copyWith(
status: InAppNotificationCenterStatus.failure,
error: UnknownException(e.toString()),
),
);
}
}
}
44 changes: 44 additions & 0 deletions lib/account/bloc/in_app_notification_center_event.dart
Original file line number Diff line number Diff line change
@@ -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<Object> 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<Object> get props => [notificationId];
}

/// Dispatched when the user requests to mark all notifications as read.
class InAppNotificationCenterMarkAllAsRead
extends InAppNotificationCenterEvent {
const InAppNotificationCenterMarkAllAsRead();
}

/// Dispatched when the user changes the selected tab in the notification center.
class InAppNotificationCenterTabChanged extends InAppNotificationCenterEvent {
const InAppNotificationCenterTabChanged(this.tabIndex);

/// The index of the newly selected tab. 0: Breaking News, 1: Digests.
final int tabIndex;

@override
List<Object> get props => [tabIndex];
}
80 changes: 80 additions & 0 deletions lib/account/bloc/in_app_notification_center_state.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
part of 'in_app_notification_center_bloc.dart';

/// The status of the [InAppNotificationCenterBloc].
enum InAppNotificationCenterStatus {
/// The initial state.
initial,

/// The state when notifications are being loaded.
loading,

/// The state when notifications have been successfully loaded.
success,

/// The state when an error has occurred.
failure,
}

/// {@template in_app_notification_center_state}
/// The state of the in-app notification center.
/// {@endtemplate}
class InAppNotificationCenterState extends Equatable {
/// {@macro in_app_notification_center_state}
const InAppNotificationCenterState({
this.status = InAppNotificationCenterStatus.initial,
this.breakingNewsNotifications = const [],
this.digestNotifications = const [],
this.currentTabIndex = 0,
this.error,
});

/// The currently selected tab index.
/// 0: Breaking News, 1: Digests.
final int currentTabIndex;

/// The list of breaking news notifications.
final List<InAppNotification> breakingNewsNotifications;

/// The list of digest notifications (daily and weekly roundups).
final List<InAppNotification> digestNotifications;

/// The current status of the notification center.
final InAppNotificationCenterStatus status;

/// The combined list of all notifications.
List<InAppNotification> get notifications => [
...breakingNewsNotifications,
...digestNotifications,
];

/// An error that occurred during notification loading or processing.
final HttpException? error;

@override
List<Object> get props => [
status,
currentTabIndex,
breakingNewsNotifications,
digestNotifications,
error ?? Object(), // Include error in props, handle nullability
];

/// Creates a copy of this state with the given fields replaced with the new
/// values.
InAppNotificationCenterState copyWith({
InAppNotificationCenterStatus? status,
HttpException? error,
int? currentTabIndex,
List<InAppNotification>? breakingNewsNotifications,
List<InAppNotification>? digestNotifications,
}) {
return InAppNotificationCenterState(
status: status ?? this.status,
error: error ?? this.error,
currentTabIndex: currentTabIndex ?? this.currentTabIndex,
breakingNewsNotifications:
breakingNewsNotifications ?? this.breakingNewsNotifications,
digestNotifications: digestNotifications ?? this.digestNotifications,
);
}
}
Loading
Loading