-
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
Merged
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 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,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()), | ||
| ), | ||
| ); | ||
| } | ||
| } | ||
| } | ||
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,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]; | ||
| } |
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,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, | ||
| ); | ||
| } | ||
| } |
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.