|
| 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 | +} |
0 commit comments