-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/in app notification center #214
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+1,281
−77
Merged
Changes from all commits
Commits
Show all changes
41 commits
Select commit
Hold shift + click to select a range
cf0ef5c
feat(l10n): add notification center translations
fulleni 38ddef7
feat(push-notification): add in-app notification support
fulleni 83a57d0
feat(app): add in-app notification repository to app initialization
fulleni 558794a
feat(app): handle in-app notification read events
fulleni 7bdb2d7
feat(app): inject in-app notification repository into App widget
fulleni 292fc03
feat(router): add in-app notification center route
fulleni 1d21317
feat(account): add notifications tile with indicator
fulleni 032a842
feat(in-app-notification): implement BLoC for notification center
fulleni 3fb38e9
feat(in-app notification center): add notification center page and li…
fulleni 8076877
feat(notifications): add onInAppNotificationReceived stream to PushNo…
fulleni 434c692
feat(notifications): persist push notifications as in-app notifications
fulleni 609e812
feat(notifications): persist push notifications as in-app notifications
fulleni 2648cd3
feat(notifications): add in-app notification stream to no-op service
fulleni a0777e5
style: change notification indicator color to primary
fulleni 4b348fe
refactor(notifications): handle foreground push notifications in AppBloc
fulleni 6537c32
refactor(push_notification): remove in-app notification repository
fulleni b5277ba
style: format
fulleni 6c28926
chore: file relocate
fulleni 9f7e048
fix(router): move notifications route inside account modal
fulleni 91cc538
refactor(router): update notifications route and remove duplicate
fulleni 2fb4f05
fix(account): update route name for notifications
fulleni 21c5304
refactor(account): simplify notification center
fulleni 74702ce
build(l10n): updated
fulleni 82be099
fix(demo): initialize in-app notifications for new users
fulleni 3f2a900
fix(account): update in-app notification filtering logic
fulleni 110ab77
feat(notifications): simulate push notifications in demo mode
fulleni dde131d
style: format
fulleni c6d07e3
test(notifications): increase delay for in-app notifications in demo …
fulleni 24d15d1
feat(README): update feature list and description
fulleni a417279
fix(account): handle null case for notification in markAsRead
fulleni 0cf7337
refactor(app): replace Random ID generation with UUID
fulleni 985e2bd
fix(account): move InAppNotificationCenterBlocProvider to router
fulleni 48c59f0
refactor(account): improve notification filtering logic
fulleni 35e5a8e
refactor(app): remove in-app notification persistence from client
fulleni 5411896
feat(account): add support for marking a single notification as read …
fulleni 934323e
feat(notification): handle notification payload in app routing
fulleni ee93b90
feat(headline-details): handle deep linking from notifications
fulleni f838424
feat(router): enhance navigation handling for deep links
fulleni 9ef0b34
fix(notifications): simulate backend behavior in NoOp service
fulleni c34e26d
build(deps): update core dependency to b01f9b4
fulleni 13c85cc
style: format
fulleni File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,283 @@ | ||
| import 'dart:async'; | ||
|
|
||
| import 'package:bloc/bloc.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'; | ||
| 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); | ||
| on<InAppNotificationCenterMarkOneAsRead>(_onMarkOneAsRead); | ||
| } | ||
|
|
||
| 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, 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 (notificationType == | ||
| PushNotificationSubscriptionDeliveryType.dailyDigest.name || | ||
| notificationType == | ||
| PushNotificationSubscriptionDeliveryType.weeklyRoundup.name || | ||
| contentType == 'digest') { | ||
| digests.add(n); | ||
| } else { | ||
| // All other types (including 'breakingOnly' notificationType, | ||
| // 'headline' contentType, or any unknown types) go to breaking news. | ||
| 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.firstWhereOrNull( | ||
| (n) => n.id == event.notificationId, | ||
| ); | ||
|
|
||
| await _markOneAsRead(notification, emit); | ||
| } | ||
|
|
||
| /// Handles marking a single notification as read from a deep-link. | ||
| Future<void> _onMarkOneAsRead( | ||
| InAppNotificationCenterMarkOneAsRead event, | ||
| Emitter<InAppNotificationCenterState> 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 ' | ||
| 'current state: ${event.notificationId}', | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| // 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<void> _markOneAsRead( | ||
| InAppNotification? notification, | ||
| Emitter<InAppNotificationCenterState> emit, | ||
| ) async { | ||
| if (notification == null) 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()), | ||
| ), | ||
| ); | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| 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]; | ||
| } | ||
|
|
||
| /// 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<Object> get props => [notificationId]; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.